1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::activity::{classify, normalize_user_text, Activity};
10
11pub struct ModelPricing {
20 pub input: f64,
21 pub output: f64,
22 pub cache_read: f64,
23 pub cache_write: f64,
25 pub cache_write_1h: f64,
27}
28
29fn family_version(model: &str, family: &str) -> Option<(u32, u32)> {
43 fn is_date(p: &str) -> bool {
44 p.len() == 8 && p.bytes().all(|b| b.is_ascii_digit())
45 }
46 let model = model.split('[').next().unwrap_or(model);
47 let rest = model.split_once(family)?.1.strip_prefix('-')?;
48 let mut parts = rest.split('-');
49 let head = parts.next()?;
50 if is_date(head) {
54 return None;
55 }
56 let major: u32 = head.parse().ok()?;
57 let minor = match parts.next() {
58 Some(p) if is_date(p) => 0,
59 Some(p) => p.parse().unwrap_or(0),
60 None => 0,
61 };
62 Some((major, minor))
63}
64
65const HAIKU_LONG_PROMPT_TOKENS: u64 = 100_000;
69
70fn is_haiku_5_5(model: &str) -> bool {
71 matches!(family_version(model, "haiku"), Some(v) if v >= (5, 5))
72}
73
74fn haiku_5_5_pricing(long_prompt: bool) -> ModelPricing {
75 if long_prompt {
76 ModelPricing {
77 input: 0.5,
78 output: 2.5,
79 cache_read: 0.05,
80 cache_write: 0.625,
81 cache_write_1h: 1.0,
82 }
83 } else {
84 ModelPricing {
85 input: 0.1,
86 output: 0.5,
87 cache_read: 0.01,
88 cache_write: 0.125,
89 cache_write_1h: 0.2,
90 }
91 }
92}
93
94pub fn pricing_for_model(model: &str) -> ModelPricing {
109 if model.contains("fable") {
110 let cache_read = match family_version(model, "fable") {
113 Some(v) if v >= (5, 1) => 0.25,
114 _ => 1.0,
115 };
116 ModelPricing {
117 input: 10.0,
118 output: 50.0,
119 cache_read,
120 cache_write: 12.5,
121 cache_write_1h: 20.0,
122 }
123 } else if matches!(family_version(model, "opus"), Some(v) if v >= (5, 5)) {
124 ModelPricing {
127 input: 4.0,
128 output: 20.0,
129 cache_read: 0.2,
130 cache_write: 5.0,
131 cache_write_1h: 8.0,
132 }
133 } else if model.contains("opus") {
134 ModelPricing {
135 input: 5.0,
136 output: 25.0,
137 cache_read: 0.5,
138 cache_write: 6.25,
139 cache_write_1h: 10.0,
140 }
141 } else if is_haiku_5_5(model) {
142 haiku_5_5_pricing(false)
143 } else if model.contains("haiku") {
144 ModelPricing {
145 input: 1.0,
146 output: 5.0,
147 cache_read: 0.1,
148 cache_write: 1.25,
149 cache_write_1h: 2.0,
150 }
151 } else if matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0)) {
152 ModelPricing {
153 input: 2.0,
154 output: 10.0,
155 cache_read: 0.2,
156 cache_write: 2.5,
157 cache_write_1h: 4.0,
158 }
159 } else {
160 ModelPricing {
162 input: 3.0,
163 output: 15.0,
164 cache_read: 0.3,
165 cache_write: 3.75,
166 cache_write_1h: 6.0,
167 }
168 }
169}
170
171pub fn pricing_for_entry(entry: &UsageEntry) -> ModelPricing {
176 let prompt = entry.input_tokens + entry.cache_read_tokens + entry.cache_write_tokens;
177 if is_haiku_5_5(&entry.model) && prompt > HAIKU_LONG_PROMPT_TOKENS {
178 haiku_5_5_pricing(true)
179 } else {
180 pricing_for_model(&entry.model)
181 }
182}
183
184pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
186
187pub fn fast_multiplier_for_model(model: &str) -> f64 {
200 if model.contains("opus-4-8") || model.contains("opus-5") {
201 2.0
202 } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
203 6.0
204 } else {
205 1.0
206 }
207}
208
209pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
217 let p = pricing_for_entry(entry);
218 let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
219 let cw_5m = entry.cache_write_tokens - cw_1h;
220 let token_cost = entry.input_tokens as f64 * p.input
221 + entry.output_tokens as f64 * p.output
222 + entry.cache_read_tokens as f64 * p.cache_read
223 + cw_5m as f64 * p.cache_write
224 + cw_1h as f64 * p.cache_write_1h;
225 let fast = if entry.speed.as_deref() == Some("fast") {
226 fast_multiplier_for_model(&entry.model)
227 } else {
228 1.0
229 };
230 (token_cost * fast) / 1_000_000.0
231 + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
232}
233
234pub fn context_window_for_model(model: &str) -> u64 {
235 if model.contains("[1m]") || model.contains("fable") || is_haiku_5_5(model) {
237 1_000_000
238 } else {
239 200_000
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct UsageEntry {
261 pub session_id: String,
262 pub timestamp_ms: u64,
264 pub model: String,
265 pub input_tokens: u64,
266 pub output_tokens: u64,
267 pub cache_read_tokens: u64,
268 pub cache_write_tokens: u64,
273 #[serde(default)]
277 pub cache_write_1h_tokens: u64,
278 #[serde(default)]
281 pub web_search_requests: u64,
282 #[serde(default)]
286 pub speed: Option<String>,
287 pub tool_uses: Vec<String>,
291 pub text_chars: u32,
294 pub activity: Activity,
297 pub entrypoint: Option<String>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct HookExecution {
311 pub session_id: String,
312 pub timestamp_ms: u64,
313 pub hook_event: String,
315 pub hook_name: String,
317 pub command: String,
319 pub exit_code: i32,
320 pub duration_ms: u64,
321 pub tool_use_id: Option<String>,
323}
324
325#[derive(Default)]
327pub struct ParsedJsonl {
328 pub entries: Vec<UsageEntry>,
329 pub hooks: Vec<HookExecution>,
330}
331
332#[derive(Debug, Clone, Serialize, Default)]
334#[serde(rename_all = "camelCase")]
335pub struct SessionUsage {
336 pub model: String,
337 pub input_tokens: u64,
338 pub output_tokens: u64,
339 pub cache_read_tokens: u64,
340 pub cache_write_tokens: u64,
341 pub total_tokens: u64,
342 pub estimated_cost_usd: f64,
343 pub context_window_size: u64,
344 pub context_window_used_pct: f64,
345 pub current_context_tokens: u64,
347 pub total_context_sent: u64,
349 pub cache_hit_pct: f64,
351 pub message_count: u64,
352 pub session_id: String,
353}
354
355#[derive(Deserialize)]
357#[serde(rename_all = "camelCase")]
358struct JsonlRaw {
359 #[serde(rename = "type")]
360 entry_type: Option<String>,
361 timestamp: Option<String>,
362 message: Option<MessageData>,
363 attachment: Option<AttachmentData>,
364 subtype: Option<String>,
366 #[allow(dead_code)]
367 hook_count: Option<usize>,
368 hook_infos: Option<Vec<HookInfoEntry>>,
369 hook_errors: Option<Vec<serde_json::Value>>,
370 prevented_continuation: Option<bool>,
371 #[serde(rename = "toolUseID")]
372 tool_use_id: Option<String>,
373 entrypoint: Option<String>,
376}
377
378#[derive(Deserialize)]
379struct MessageData {
380 id: Option<String>,
381 model: Option<String>,
382 usage: Option<UsageData>,
383 #[serde(default)]
388 content: Option<serde_json::Value>,
389}
390
391#[derive(Deserialize, Default)]
392struct UsageData {
393 input_tokens: Option<u64>,
394 output_tokens: Option<u64>,
395 cache_read_input_tokens: Option<u64>,
396 cache_creation_input_tokens: Option<u64>,
399 cache_creation: Option<CacheCreation>,
401 server_tool_use: Option<ServerToolUse>,
403 speed: Option<String>,
405}
406
407#[derive(Deserialize, Default)]
409struct CacheCreation {
410 ephemeral_5m_input_tokens: Option<u64>,
411 ephemeral_1h_input_tokens: Option<u64>,
412}
413
414#[derive(Deserialize, Default)]
417struct ServerToolUse {
418 web_search_requests: Option<u64>,
419}
420
421#[derive(Deserialize)]
423#[serde(rename_all = "camelCase")]
424struct HookInfoEntry {
425 command: Option<String>,
426 duration_ms: Option<u64>,
427}
428
429#[derive(Deserialize)]
431#[serde(rename_all = "camelCase")]
432struct AttachmentData {
433 #[serde(rename = "type")]
434 attachment_type: Option<String>,
435 hook_name: Option<String>,
436 hook_event: Option<String>,
437 command: Option<String>,
438 exit_code: Option<i32>,
439 duration_ms: Option<u64>,
440 #[serde(rename = "toolUseID")]
441 tool_use_id: Option<String>,
442}
443
444#[derive(Deserialize)]
446#[serde(rename_all = "camelCase")]
447struct ActiveSession {
448 session_id: String,
449 cwd: String,
450 started_at: u64,
451}
452
453use super::MAX_JSONL_BYTES;
460
461pub fn encode_project_path(project_path: &str) -> String {
471 let normalized = project_path.replace('/', "\\");
472 let mut result = String::new();
473 let mut chars = normalized.chars().peekable();
474
475 while let Some(ch) = chars.next() {
476 if ch == ':' {
477 if chars.peek() == Some(&'\\') {
478 chars.next();
479 result.push_str("--");
480 } else {
481 result.push('-');
482 }
483 } else if ch == '\\' {
484 result.push('-');
485 } else {
486 result.push(ch);
487 }
488 }
489
490 while result.ends_with('-') {
491 result.pop();
492 }
493
494 result
495}
496
497struct AssistantContent {
499 tool_uses: Vec<String>,
500 bash_cmds: Vec<String>,
501 text_chars: u32,
502}
503
504fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
508 let mut out = AssistantContent {
509 tool_uses: Vec::new(),
510 bash_cmds: Vec::new(),
511 text_chars: 0,
512 };
513 let arr = match content.and_then(|v| v.as_array()) {
514 Some(a) => a,
515 None => return out,
516 };
517
518 for block in arr {
519 let block_type = match block.get("type").and_then(|v| v.as_str()) {
520 Some(t) => t,
521 None => continue,
522 };
523 match block_type {
524 "tool_use" => {
525 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
526 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
529 if let Some(cmd) = block
530 .get("input")
531 .and_then(|i| i.get("command"))
532 .and_then(|c| c.as_str())
533 {
534 out.bash_cmds.push(cmd.to_string());
535 }
536 }
537 out.tool_uses.push(name.to_string());
538 }
539 }
540 "text" => {
541 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
542 out.text_chars = out
545 .text_chars
546 .saturating_add(text.chars().count() as u32);
547 }
548 }
549 _ => {}
550 }
551 }
552
553 out
554}
555
556fn sanitize_hook_command_label(cmd: &str) -> String {
575 const MAX_LEN: usize = 64;
576 let first_line = cmd.lines().next().unwrap_or("").trim();
577 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
578 if collapsed.chars().count() <= MAX_LEN {
579 return collapsed;
580 }
581 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
582 format!("{truncated}…")
583}
584
585fn snake_to_pascal(s: &str) -> String {
587 s.split('_')
588 .filter(|w| !w.is_empty())
589 .map(|w| {
590 let mut c = w.chars();
591 match c.next() {
592 Some(first) => {
593 let mut out = first.to_uppercase().to_string();
594 out.extend(c);
595 out
596 }
597 None => String::new(),
598 }
599 })
600 .collect()
601}
602
603fn extract_user_text(content: &serde_json::Value) -> Option<String> {
606 match content {
607 serde_json::Value::String(s) => {
608 if s.is_empty() {
609 None
610 } else {
611 Some(s.clone())
612 }
613 }
614 serde_json::Value::Array(arr) => {
615 let mut parts: Vec<&str> = Vec::new();
616 for item in arr {
617 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
618 if t == "text" {
619 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
620 if !text.is_empty() {
621 parts.push(text);
622 }
623 }
624 }
625 }
626 if parts.is_empty() {
627 None
628 } else {
629 Some(parts.join(" "))
630 }
631 }
632 _ => None,
633 }
634}
635
636struct EntryAccumulator {
639 out_idx: usize,
640 bash_cmds: Vec<String>,
641 user_text_at_first: Option<String>,
642}
643
644pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
668 let meta = fs::metadata(path)?;
669 if meta.len() > MAX_JSONL_BYTES {
670 tracing::warn!(
671 path = %path.display(),
672 size = meta.len(),
673 "skipping JSONL larger than {}MB",
674 MAX_JSONL_BYTES / 1024 / 1024
675 );
676 return Ok(ParsedJsonl::default());
677 }
678
679 let file = fs::File::open(path)?;
680 let reader = BufReader::new(file);
681
682 let session_id = path
683 .file_stem()
684 .and_then(|s| s.to_str())
685 .unwrap_or("")
686 .to_string();
687
688 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
689 let mut out: Vec<UsageEntry> = Vec::new();
690 let mut hooks: Vec<HookExecution> = Vec::new();
691 let mut last_user_text: Option<String> = None;
692
693 for line in reader.lines() {
694 let line = match line {
695 Ok(l) => l,
696 Err(_) => continue,
697 };
698 let line = line.trim();
699 if line.is_empty() {
700 continue;
701 }
702
703 let raw: JsonlRaw = match serde_json::from_str(line) {
704 Ok(r) => r,
705 Err(_) => continue,
706 };
707
708 let entry_type = raw.entry_type.as_deref();
709
710 if entry_type == Some("user") {
713 if let Some(msg) = raw.message.as_ref() {
714 if let Some(content) = msg.content.as_ref() {
715 if let Some(text) = extract_user_text(content) {
716 last_user_text = Some(normalize_user_text(&text));
717 }
718 }
719 }
720 continue;
721 }
722
723 if entry_type == Some("attachment") {
725 if let Some(att) = raw.attachment {
726 if att.attachment_type.as_deref() == Some("hook_success") {
727 let timestamp_ms = raw
728 .timestamp
729 .as_deref()
730 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
731 .map(|dt| dt.timestamp_millis().max(0) as u64)
732 .unwrap_or(0);
733 hooks.push(HookExecution {
734 session_id: session_id.clone(),
735 timestamp_ms,
736 hook_event: att.hook_event.unwrap_or_default(),
737 hook_name: att.hook_name.unwrap_or_default(),
738 command: att.command.unwrap_or_default(),
739 exit_code: att.exit_code.unwrap_or(0),
740 duration_ms: att.duration_ms.unwrap_or(0),
741 tool_use_id: att.tool_use_id,
742 });
743 }
744 }
745 continue;
746 }
747
748 if entry_type == Some("system") {
751 if let Some(subtype) = raw.subtype.as_deref() {
752 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
753 let hook_event = snake_to_pascal(event_snake);
754 let timestamp_ms = raw
755 .timestamp
756 .as_deref()
757 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
758 .map(|dt| dt.timestamp_millis().max(0) as u64)
759 .unwrap_or(0);
760 let had_errors = raw
761 .hook_errors
762 .as_ref()
763 .is_some_and(|e| !e.is_empty());
764 let prevented = raw.prevented_continuation.unwrap_or(false);
765 if let Some(infos) = raw.hook_infos {
766 for info in infos {
767 let cmd = info.command.unwrap_or_default();
768 let hook_name =
769 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
770 hooks.push(HookExecution {
771 session_id: session_id.clone(),
772 timestamp_ms,
773 hook_event: hook_event.clone(),
774 hook_name,
775 command: cmd,
776 exit_code: if had_errors || prevented { 1 } else { 0 },
777 duration_ms: info.duration_ms.unwrap_or(0),
778 tool_use_id: raw.tool_use_id.clone(),
779 });
780 }
781 }
782 }
783 }
784 continue;
785 }
786
787 if entry_type != Some("assistant") {
788 continue;
789 }
790
791 let msg = match raw.message {
792 Some(m) => m,
793 None => continue,
794 };
795 let mid = match msg.id {
796 Some(id) => id,
797 None => continue,
798 };
799
800 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
801
802 if let Some(acc) = accums.get_mut(&mid) {
805 let entry = &mut out[acc.out_idx];
806 entry
807 .tool_uses
808 .append(&mut content_parsed.tool_uses);
809 entry.text_chars = entry
810 .text_chars
811 .saturating_add(content_parsed.text_chars);
812 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
813 continue;
814 }
815
816 let model = msg.model.unwrap_or_default();
822 if model == "<synthetic>" {
823 continue;
824 }
825
826 let usage = msg.usage.unwrap_or_default();
827 let timestamp_ms = raw
828 .timestamp
829 .as_deref()
830 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
831 .map(|dt| dt.timestamp_millis().max(0) as u64)
832 .unwrap_or(0);
833
834 let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
838 let (eph_5m, eph_1h) = usage
839 .cache_creation
840 .as_ref()
841 .map(|c| {
842 (
843 c.ephemeral_5m_input_tokens.unwrap_or(0),
844 c.ephemeral_1h_input_tokens.unwrap_or(0),
845 )
846 })
847 .unwrap_or((0, 0));
848 let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
849 let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
850 let web_search_requests = usage
851 .server_tool_use
852 .as_ref()
853 .map(|s| s.web_search_requests.unwrap_or(0))
854 .unwrap_or(0);
855 let speed = usage.speed.clone();
856
857 accums.insert(
861 mid.clone(),
862 EntryAccumulator {
863 out_idx: out.len(),
864 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
865 user_text_at_first: last_user_text.clone(),
866 },
867 );
868 out.push(UsageEntry {
869 session_id: session_id.clone(),
870 timestamp_ms,
871 model,
872 input_tokens: usage.input_tokens.unwrap_or(0),
873 output_tokens: usage.output_tokens.unwrap_or(0),
874 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
875 cache_write_tokens,
876 cache_write_1h_tokens,
877 web_search_requests,
878 speed,
879 tool_uses: content_parsed.tool_uses,
880 text_chars: content_parsed.text_chars,
881 activity: Activity::General,
882 entrypoint: raw.entrypoint.clone(),
883 });
884 }
885
886 for acc in accums.values() {
889 let entry = &mut out[acc.out_idx];
890 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
891 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
892 entry.activity = classify(
893 &tool_refs,
894 &bash_refs,
895 acc.user_text_at_first.as_deref(),
896 entry.text_chars,
897 );
898 }
899
900 Ok(ParsedJsonl {
901 entries: out,
902 hooks,
903 })
904}
905
906pub fn find_current_session_jsonl(
910 config_dir: &str,
911 project_path: &str,
912 spawned_at: SystemTime,
913) -> Option<PathBuf> {
914 let config = Path::new(config_dir);
915 let encoded = encode_project_path(project_path);
916 let projects_dir = config.join("projects").join(&encoded);
917
918 if !projects_dir.exists() {
919 return None;
920 }
921
922 let sessions_dir = config.join("sessions");
925 if sessions_dir.exists() {
926 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
927 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
928 if jsonl_path.exists() {
929 return Some(jsonl_path);
930 }
931 }
932 }
933
934 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
939
940 if let Ok(entries) = fs::read_dir(&projects_dir) {
941 for entry in entries.flatten() {
942 let path = entry.path();
943 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
944 continue;
945 }
946 if let Ok(meta) = path.metadata() {
947 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
948 if created >= spawned_at {
949 candidates.push((path, created));
950 }
951 }
952 }
953 }
954
955 candidates.sort_by_key(|a| a.1);
957 candidates.into_iter().next().map(|(p, _)| p)
958}
959
960fn find_active_session_id(
963 sessions_dir: &Path,
964 project_path: &str,
965 spawned_at: SystemTime,
966) -> Option<String> {
967 let spawned_ms = spawned_at
968 .duration_since(SystemTime::UNIX_EPOCH)
969 .ok()?
970 .as_millis() as u64;
971
972 let threshold_ms = spawned_ms.saturating_sub(10_000);
974
975 let normalized_project = normalize_path(project_path);
976
977 let mut best: Option<(String, u64)> = None;
978
979 if let Ok(entries) = fs::read_dir(sessions_dir) {
980 for entry in entries.flatten() {
981 let path = entry.path();
982 if path.extension().and_then(|e| e.to_str()) != Some("json") {
983 continue;
984 }
985 if let Ok(content) = fs::read_to_string(&path) {
986 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
987 let session_cwd = normalize_path(&session.cwd);
988 if session_cwd == normalized_project && session.started_at >= threshold_ms {
989 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
995 best = Some((session.session_id, session.started_at));
996 }
997 }
998 }
999 }
1000 }
1001 }
1002
1003 best.map(|(id, _)| id)
1004}
1005
1006fn normalize_path(p: &str) -> String {
1008 p.replace('\\', "/")
1009 .trim_end_matches('/')
1010 .to_lowercase()
1011}
1012
1013pub fn reduce_session(
1022 entries: &[UsageEntry],
1023 launched_model: &str,
1024 session_id_hint: &str,
1025) -> Option<SessionUsage> {
1026 if entries.is_empty() {
1027 return None;
1028 }
1029
1030 let mut input_tokens = 0u64;
1031 let mut output_tokens = 0u64;
1032 let mut cache_read = 0u64;
1033 let mut cache_write = 0u64;
1034 let mut model_counts: HashMap<&str, u64> = HashMap::new();
1035
1036 for e in entries {
1037 input_tokens += e.input_tokens;
1038 output_tokens += e.output_tokens;
1039 cache_read += e.cache_read_tokens;
1040 cache_write += e.cache_write_tokens;
1041 if !e.model.is_empty() {
1042 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
1043 }
1044 }
1045
1046 let primary_model = model_counts
1047 .into_iter()
1048 .max_by_key(|(_, c)| *c)
1049 .map(|(m, _)| m.to_string())
1050 .unwrap_or_else(|| {
1051 entries
1052 .last()
1053 .map(|e| e.model.clone())
1054 .unwrap_or_default()
1055 });
1056
1057 let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
1061
1062 let context_model = if launched_model.is_empty() {
1063 &primary_model
1064 } else {
1065 launched_model
1066 };
1067 let context_window = context_window_for_model(context_model);
1068
1069 let last = entries.last().unwrap();
1070 let last_context_used =
1071 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
1072 let context_pct = if context_window > 0 {
1073 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
1074 } else {
1075 0.0
1076 };
1077
1078 let total = input_tokens + output_tokens + cache_read + cache_write;
1079 let total_context_sent = input_tokens + cache_read + cache_write;
1080
1081 let cache_denom = input_tokens + cache_read + cache_write;
1086 let cache_hit_pct = if cache_denom > 0 {
1087 (cache_read as f64 / cache_denom as f64) * 100.0
1088 } else {
1089 0.0
1090 };
1091
1092 let session_id = entries
1093 .first()
1094 .map(|e| e.session_id.clone())
1095 .filter(|s| !s.is_empty())
1096 .unwrap_or_else(|| session_id_hint.to_string());
1097
1098 Some(SessionUsage {
1099 model: primary_model,
1100 input_tokens,
1101 output_tokens,
1102 cache_read_tokens: cache_read,
1103 cache_write_tokens: cache_write,
1104 total_tokens: total,
1105 estimated_cost_usd: estimated_cost,
1106 context_window_size: context_window,
1107 context_window_used_pct: context_pct,
1108 current_context_tokens: last_context_used,
1109 total_context_sent,
1110 cache_hit_pct,
1111 message_count: entries.len() as u64,
1112 session_id,
1113 })
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use std::io::Write;
1120 use tempfile::TempDir;
1121
1122 #[test]
1123 fn test_encode_project_path_drive_root() {
1124 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1125 }
1126
1127 #[test]
1128 fn test_encode_project_path_deep() {
1129 assert_eq!(
1130 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1131 "C--Users-Seven-Desktop-SF-project"
1132 );
1133 }
1134
1135 #[test]
1136 fn test_encode_project_path_forward_slashes() {
1137 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1138 }
1139
1140 #[test]
1141 fn test_encode_project_path_trailing_slash() {
1142 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1143 }
1144
1145 #[test]
1146 fn test_encode_project_path_macos() {
1147 assert_eq!(
1148 encode_project_path("/Users/seven/Loom"),
1149 "-Users-seven-Loom"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_encode_project_path_linux() {
1155 assert_eq!(
1156 encode_project_path("/home/alice/projects/foo"),
1157 "-home-alice-projects-foo"
1158 );
1159 }
1160
1161 #[test]
1162 fn test_encode_project_path_unix_trailing_slash() {
1163 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1164 }
1165
1166 #[test]
1167 fn test_normalize_path() {
1168 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1169 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1170 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1171 }
1172
1173 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1174 let tmp = TempDir::new().unwrap();
1175 let path = tmp.path().join("abc123.jsonl");
1176 let mut f = fs::File::create(&path).unwrap();
1177 for l in lines {
1178 writeln!(f, "{}", l).unwrap();
1179 }
1180 (tmp, path)
1181 }
1182
1183 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1184 format!(
1185 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"{model}","usage":{{"input_tokens":{inp},"output_tokens":{out},"cache_read_input_tokens":{cr},"cache_creation_input_tokens":{cw}}}}}}}"#
1186 )
1187 }
1188
1189 #[test]
1190 fn parse_deduplicates_repeated_message_ids() {
1191 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1194 let a1_dup = a1.clone();
1195 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1196 let a2_dup = a2.clone();
1197 let a2_dup2 = a2.clone();
1198 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1199
1200 let entries = parse_jsonl_entries(&path).unwrap().entries;
1201 assert_eq!(entries.len(), 2, "should dedup by message.id");
1202 assert_eq!(entries[0].input_tokens, 15);
1203 assert_eq!(entries[0].output_tokens, 1463);
1204 assert_eq!(entries[1].input_tokens, 10);
1205 }
1206
1207 #[test]
1208 fn parse_captures_entrypoint_from_assistant_row() {
1209 let headless = r#"{"type":"assistant","timestamp":"2026-05-13T07:19:12.573Z","entrypoint":"sdk-cli","message":{"id":"msg_h1","model":"claude-opus-4-7","usage":{"input_tokens":5,"output_tokens":628,"cache_read_input_tokens":0,"cache_creation_input_tokens":14266}}}"#;
1213 let interactive = r#"{"type":"assistant","timestamp":"2026-05-13T08:00:00.000Z","entrypoint":"cli","message":{"id":"msg_i1","model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1214 let legacy = r#"{"type":"assistant","timestamp":"2026-05-13T09:00:00.000Z","message":{"id":"msg_l1","model":"claude-sonnet-4-6","usage":{"input_tokens":3,"output_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1215 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1216
1217 let entries = parse_jsonl_entries(&path).unwrap().entries;
1218 assert_eq!(entries.len(), 3);
1219 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1220 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1221 assert_eq!(entries[2].entrypoint, None);
1222 }
1223
1224 #[test]
1225 fn parse_skips_non_assistant_entries() {
1226 let lines = [
1227 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1228 r#"{"type":"permission-mode"}"#.to_string(),
1229 r#"{"type":"file-history-snapshot"}"#.to_string(),
1230 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1231 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1232 ];
1233 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1234 let (_tmp, path) = write_jsonl(&line_refs);
1235
1236 let entries = parse_jsonl_entries(&path).unwrap().entries;
1237 assert_eq!(entries.len(), 1);
1238 assert_eq!(entries[0].model, "claude-sonnet-4-6");
1239 }
1240
1241 #[test]
1242 fn parse_tolerates_malformed_lines_and_empty_lines() {
1243 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1244 let lines = vec![
1245 "",
1246 "not json at all",
1247 r#"{"type":"assistant","message":null}"#,
1248 r#"{"type":"assistant","message":{"id":null}}"#,
1249 &good,
1250 " ",
1251 r#"{"broken":"trailing"#,
1252 ];
1253 let (_tmp, path) = write_jsonl(&lines);
1254
1255 let entries = parse_jsonl_entries(&path).unwrap().entries;
1256 assert_eq!(entries.len(), 1);
1257 }
1258
1259 #[test]
1260 fn parse_extracts_session_id_from_file_stem() {
1261 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1262 let (_tmp, path) = write_jsonl(&[&line]);
1263 let entries = parse_jsonl_entries(&path).unwrap().entries;
1264 assert_eq!(entries[0].session_id, "abc123");
1265 }
1266
1267 #[test]
1268 fn parse_parses_iso_timestamps() {
1269 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1270 let (_tmp, path) = write_jsonl(&[&line]);
1271 let entries = parse_jsonl_entries(&path).unwrap().entries;
1272 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1274 }
1275
1276 #[test]
1277 fn parse_skips_synthetic_model_rows() {
1278 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1281 let synthetic = r#"{"type":"assistant","timestamp":"2026-04-11T00:27:39.000Z","message":{"id":"msg_synth","model":"<synthetic>","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1282 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1283 let entries = parse_jsonl_entries(&path).unwrap().entries;
1284 assert_eq!(entries.len(), 1);
1285 assert_eq!(entries[0].model, "claude-opus-4-6");
1286 }
1287
1288 #[test]
1289 fn parse_handles_missing_usage_fields() {
1290 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1291 let (_tmp, path) = write_jsonl(&[line]);
1292 let entries = parse_jsonl_entries(&path).unwrap().entries;
1293 assert_eq!(entries.len(), 1);
1294 assert_eq!(entries[0].input_tokens, 0);
1295 assert_eq!(entries[0].cache_read_tokens, 0);
1296 }
1297
1298 #[test]
1299 fn reduce_computes_primary_model_by_mode() {
1300 let e = |m: &str| UsageEntry {
1301 session_id: "s".into(),
1302 timestamp_ms: 0,
1303 model: m.into(),
1304 input_tokens: 1,
1305 output_tokens: 1,
1306 cache_read_tokens: 0,
1307 cache_write_tokens: 0,
1308 cache_write_1h_tokens: 0,
1309 web_search_requests: 0,
1310 speed: None,
1311 tool_uses: Vec::new(),
1312 text_chars: 0,
1313 activity: Activity::General,
1314 entrypoint: None,
1315 };
1316 let entries = vec![
1317 e("claude-opus-4-6"),
1318 e("claude-opus-4-6"),
1319 e("claude-opus-4-6"),
1320 e("claude-sonnet-4-6"),
1321 ];
1322 let u = reduce_session(&entries, "", "sid").unwrap();
1323 assert_eq!(u.model, "claude-opus-4-6");
1324 assert_eq!(u.message_count, 4);
1325 }
1326
1327 #[test]
1328 fn reduce_uses_launched_model_for_context_window() {
1329 let entries = vec![UsageEntry {
1330 session_id: "s".into(),
1331 timestamp_ms: 0,
1332 model: "claude-opus-4-6".into(),
1334 input_tokens: 1000,
1335 output_tokens: 0,
1336 cache_read_tokens: 0,
1337 cache_write_tokens: 0,
1338 cache_write_1h_tokens: 0,
1339 web_search_requests: 0,
1340 speed: None,
1341 tool_uses: Vec::new(),
1342 text_chars: 0,
1343 activity: Activity::General,
1344 entrypoint: None,
1345 }];
1346 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1347 let without = reduce_session(&entries, "", "sid").unwrap();
1348 assert_eq!(with_1m.context_window_size, 1_000_000);
1349 assert_eq!(without.context_window_size, 200_000);
1350 }
1351
1352 #[test]
1353 fn reduce_context_pct_uses_last_entry_only() {
1354 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1356 session_id: "s".into(),
1357 timestamp_ms: 0,
1358 model: "claude-opus-4-6".into(),
1359 input_tokens: input,
1360 output_tokens: 0,
1361 cache_read_tokens: cr,
1362 cache_write_tokens: cw,
1363 cache_write_1h_tokens: 0,
1364 web_search_requests: 0,
1365 speed: None,
1366 tool_uses: Vec::new(),
1367 text_chars: 0,
1368 activity: Activity::General,
1369 entrypoint: None,
1370 };
1371 let entries = vec![
1372 mk(1000, 0, 0),
1373 mk(500, 0, 0),
1374 mk(5, 160_000, 0), ];
1376 let u = reduce_session(&entries, "", "sid").unwrap();
1377 assert_eq!(u.current_context_tokens, 160_005);
1378 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1379 }
1380
1381 #[test]
1382 fn reduce_cache_hit_pct() {
1383 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1384 session_id: "s".into(),
1385 timestamp_ms: 0,
1386 model: "claude-opus-4-6".into(),
1387 input_tokens: input,
1388 output_tokens: 1,
1389 cache_read_tokens: cr,
1390 cache_write_tokens: cw,
1391 cache_write_1h_tokens: 0,
1392 web_search_requests: 0,
1393 speed: None,
1394 tool_uses: Vec::new(),
1395 text_chars: 0,
1396 activity: Activity::General,
1397 entrypoint: None,
1398 };
1399 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1401 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1402
1403 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1405 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1406
1407 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1409 assert_eq!(u.cache_hit_pct, 0.0);
1410 }
1411
1412 #[test]
1413 fn reduce_total_context_sent_is_cumulative() {
1414 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1415 session_id: "s".into(),
1416 timestamp_ms: 0,
1417 model: "claude-opus-4-6".into(),
1418 input_tokens: input,
1419 output_tokens: 0,
1420 cache_read_tokens: cr,
1421 cache_write_tokens: cw,
1422 cache_write_1h_tokens: 0,
1423 web_search_requests: 0,
1424 speed: None,
1425 tool_uses: Vec::new(),
1426 text_chars: 0,
1427 activity: Activity::General,
1428 entrypoint: None,
1429 };
1430 let u = reduce_session(
1431 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1432 "",
1433 "sid",
1434 )
1435 .unwrap();
1436 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1437 }
1438
1439 #[test]
1440 fn reduce_cost_matches_opus_pricing() {
1441 let entries = vec![UsageEntry {
1443 session_id: "s".into(),
1444 timestamp_ms: 0,
1445 model: "claude-opus-4-6".into(),
1446 input_tokens: 1_000_000, output_tokens: 1_000_000, cache_read_tokens: 1_000_000, cache_write_tokens: 1_000_000, cache_write_1h_tokens: 0,
1451 web_search_requests: 0,
1452 speed: None,
1453 tool_uses: Vec::new(),
1454 text_chars: 0,
1455 activity: Activity::General,
1456 entrypoint: None,
1457 }];
1458 let u = reduce_session(&entries, "", "sid").unwrap();
1459 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1460 }
1461
1462 #[test]
1463 fn reduce_empty_returns_none() {
1464 assert!(reduce_session(&[], "", "sid").is_none());
1465 }
1466
1467 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1470 format!(
1471 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-6","usage":{{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"content":{content_json}}}}}"#
1472 )
1473 }
1474
1475 #[test]
1476 fn parse_extracts_tool_use_names() {
1477 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"a.rs"}},{"type":"tool_use","id":"tu_2","name":"Read","input":{"file_path":"b.rs"}}]"#;
1478 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1479 let (_tmp, path) = write_jsonl(&[&line]);
1480 let entries = parse_jsonl_entries(&path).unwrap().entries;
1481 assert_eq!(entries.len(), 1);
1482 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1483 }
1484
1485 #[test]
1486 fn parse_captures_text_char_count() {
1487 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1488 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1489 let (_tmp, path) = write_jsonl(&[&line]);
1490 let entries = parse_jsonl_entries(&path).unwrap().entries;
1491 assert_eq!(entries[0].text_chars, 12); }
1493
1494 #[test]
1495 fn parse_handles_missing_content_gracefully() {
1496 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1499 let (_tmp, path) = write_jsonl(&[&line]);
1500 let entries = parse_jsonl_entries(&path).unwrap().entries;
1501 assert_eq!(entries.len(), 1);
1502 assert!(entries[0].tool_uses.is_empty());
1503 assert_eq!(entries[0].text_chars, 0);
1504 assert_eq!(entries[0].activity, Activity::General);
1505 }
1506
1507 #[test]
1508 fn parse_classifies_edit_plus_debug_as_debugging() {
1509 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"Please fix the bug in login.rs"}}"#.to_string();
1511 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1512 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1513 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1514 let entries = parse_jsonl_entries(&path).unwrap().entries;
1515 assert_eq!(entries.len(), 1);
1516 assert_eq!(entries[0].activity, Activity::Debugging);
1517 }
1518
1519 #[test]
1520 fn parse_classifies_bash_pytest_as_testing() {
1521 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1522 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1523 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1524 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1525 let entries = parse_jsonl_entries(&path).unwrap().entries;
1526 assert_eq!(entries[0].activity, Activity::Testing);
1527 }
1528
1529 #[test]
1530 fn parse_classifies_bash_git_as_gitops() {
1531 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1532 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1533 let (_tmp, path) = write_jsonl(&[&line]);
1534 let entries = parse_jsonl_entries(&path).unwrap().entries;
1535 assert_eq!(entries[0].activity, Activity::GitOps);
1536 }
1537
1538 #[test]
1539 fn parse_tool_result_row_does_not_overwrite_user_text() {
1540 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1545 let asst1 = assistant_with_content(
1546 "msg_1",
1547 "2026-04-14T08:27:35.000Z",
1548 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1549 );
1550 let tool_result = r#"{"type":"user","timestamp":"2026-04-14T08:27:36.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"file contents..."}]}}"#.to_string();
1551 let asst2 = assistant_with_content(
1552 "msg_2",
1553 "2026-04-14T08:27:40.000Z",
1554 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1555 );
1556 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1557 let entries = parse_jsonl_entries(&path).unwrap().entries;
1558 assert_eq!(entries.len(), 2);
1559 assert_eq!(entries[0].activity, Activity::Exploration);
1561 assert_eq!(entries[1].activity, Activity::Refactor);
1564 }
1565
1566 #[test]
1567 fn parse_classifies_agent_turn_as_delegation() {
1568 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1569 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1570 let (_tmp, path) = write_jsonl(&[&line]);
1571 let entries = parse_jsonl_entries(&path).unwrap().entries;
1572 assert_eq!(entries[0].activity, Activity::Delegation);
1573 }
1574
1575 #[test]
1576 fn parse_pure_text_turn_is_conversation() {
1577 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1578 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1579 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1580 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1581 let entries = parse_jsonl_entries(&path).unwrap().entries;
1582 assert_eq!(entries[0].activity, Activity::Conversation);
1583 assert!(entries[0].text_chars > 0);
1584 }
1585
1586 #[test]
1587 fn parse_user_array_content_extracts_text_blocks() {
1588 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":[{"type":"text","text":"fix the crash"}]}}"#.to_string();
1591 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1592 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1593 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1594 let entries = parse_jsonl_entries(&path).unwrap().entries;
1595 assert_eq!(entries[0].activity, Activity::Debugging);
1596 }
1597
1598 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1604 format!(
1605 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-7","usage":{{"input_tokens":6,"output_tokens":746,"cache_read_input_tokens":21859,"cache_creation_input_tokens":33027}},"content":{content_json}}}}}"#
1606 )
1607 }
1608
1609 #[test]
1610 fn parse_merges_tool_uses_across_split_content_rows() {
1611 let thinking = assistant_split_row(
1617 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1618 "2026-05-13T09:09:03.500Z",
1619 r#"[{"type":"thinking","thinking":"plan"}]"#,
1620 );
1621 let text = assistant_split_row(
1622 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1623 "2026-05-13T09:09:03.550Z",
1624 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1625 );
1626 let tu1 = assistant_split_row(
1627 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1628 "2026-05-13T09:09:03.600Z",
1629 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1630 );
1631 let tu2 = assistant_split_row(
1632 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1633 "2026-05-13T09:09:03.700Z",
1634 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1635 );
1636 let tu3 = assistant_split_row(
1637 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1638 "2026-05-13T09:09:03.800Z",
1639 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1640 );
1641 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1642
1643 let entries = parse_jsonl_entries(&path).unwrap().entries;
1644 assert_eq!(entries.len(), 1, "still one entry per message.id");
1645
1646 let e = &entries[0];
1647 assert_eq!(
1648 e.tool_uses,
1649 vec![
1650 "mcp__example__list_configs".to_string(),
1651 "mcp__example__get_report".to_string(),
1652 "Edit".to_string(),
1653 ],
1654 "all tool_use names merged in row order"
1655 );
1656 assert!(e.text_chars > 0, "text from the text row is preserved");
1657
1658 assert_eq!(e.input_tokens, 6);
1662 assert_eq!(e.output_tokens, 746);
1663 assert_eq!(e.cache_read_tokens, 21859);
1664 assert_eq!(e.cache_write_tokens, 33027);
1665 }
1666
1667 #[test]
1668 fn parse_split_row_classifies_against_merged_content() {
1669 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"please fix the bug in auth.rs"}}"#.to_string();
1674 let thinking = assistant_split_row(
1675 "msg_split",
1676 "2026-05-13T09:09:01.000Z",
1677 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1678 );
1679 let edit = assistant_split_row(
1680 "msg_split",
1681 "2026-05-13T09:09:02.000Z",
1682 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1683 );
1684 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1685
1686 let entries = parse_jsonl_entries(&path).unwrap().entries;
1687 assert_eq!(entries.len(), 1);
1688 assert_eq!(entries[0].activity, Activity::Debugging);
1689 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1690 }
1691
1692 #[test]
1693 fn parse_split_row_bash_commands_feed_classifier() {
1694 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1697 let text = assistant_split_row(
1698 "msg_bash",
1699 "2026-05-13T09:09:01.000Z",
1700 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1701 );
1702 let bash = assistant_split_row(
1703 "msg_bash",
1704 "2026-05-13T09:09:02.000Z",
1705 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1706 );
1707 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1708
1709 let entries = parse_jsonl_entries(&path).unwrap().entries;
1710 assert_eq!(entries[0].activity, Activity::Testing);
1711 }
1712
1713 #[test]
1716 fn parse_extracts_hook_success_events() {
1717 let hook_line = r#"{"type":"attachment","timestamp":"2026-04-15T18:08:50.000Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_abc","command":"verify-edit.sh","exitCode":0,"durationMs":90}}"#;
1718 let hook_fail = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:00.000Z","attachment":{"type":"hook_success","hookName":"PreToolUse:Bash","hookEvent":"PreToolUse","toolUseID":"tu_def","command":"lint-check.sh","exitCode":1,"durationMs":250}}"#;
1719 let hook_msg = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:01.000Z","attachment":{"type":"hook_system_message","hookName":"Stop","hookEvent":"Stop","content":"reminder text"}}"#;
1721 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1722 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1723
1724 let parsed = parse_jsonl_entries(&path).unwrap();
1725 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1726 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1727
1728 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1729 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1730 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1731 assert_eq!(parsed.hooks[0].exit_code, 0);
1732 assert_eq!(parsed.hooks[0].duration_ms, 90);
1733
1734 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1735
1736 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1737 assert_eq!(parsed.hooks[1].exit_code, 1);
1738 assert_eq!(parsed.hooks[1].duration_ms, 250);
1739 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1740 }
1741
1742 #[test]
1743 fn parse_ignores_attachment_without_hook_data() {
1744 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1746 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1747 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1748
1749 let parsed = parse_jsonl_entries(&path).unwrap();
1750 assert_eq!(parsed.entries.len(), 1);
1751 assert!(parsed.hooks.is_empty());
1752 }
1753
1754 #[test]
1755 fn snake_to_pascal_converts_correctly() {
1756 assert_eq!(snake_to_pascal("stop"), "Stop");
1757 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1758 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1759 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1760 }
1761
1762 #[test]
1763 fn parse_extracts_system_hook_summary_v2() {
1764 let stop_summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"Running build checks...","durationMs":5227}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-04-25T09:06:12.854Z","uuid":"abc","toolUseID":"tu_xyz","sessionId":"sess-1","version":"2.1.119"}"#;
1766 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1767 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1768
1769 let parsed = parse_jsonl_entries(&path).unwrap();
1770 assert_eq!(parsed.entries.len(), 1);
1771 assert_eq!(parsed.hooks.len(), 1);
1772
1773 let h = &parsed.hooks[0];
1774 assert_eq!(h.hook_event, "Stop");
1775 assert_eq!(h.command, "Running build checks...");
1776 assert_eq!(h.duration_ms, 5227);
1777 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1778 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1779 }
1780
1781 #[test]
1782 fn parse_system_hook_summary_with_errors() {
1783 let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":["something broke"],"preventedContinuation":true,"timestamp":"2026-04-25T10:00:00.000Z","uuid":"def","toolUseID":"tu_err","sessionId":"sess-2","version":"2.1.119"}"#;
1784 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1785 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1786
1787 let parsed = parse_jsonl_entries(&path).unwrap();
1788 assert_eq!(parsed.hooks.len(), 1);
1789 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1790 }
1791
1792 #[test]
1793 fn parse_system_hook_summary_multiple_hooks() {
1794 let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"tsc --noEmit","durationMs":1200},{"command":"cargo check","durationMs":3500}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T11:00:00.000Z","uuid":"ghi","toolUseID":"tu_multi","sessionId":"sess-3","version":"2.1.119"}"#;
1795 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1796 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1797
1798 let parsed = parse_jsonl_entries(&path).unwrap();
1799 assert_eq!(parsed.hooks.len(), 2);
1800 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1801 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1802 assert_eq!(parsed.hooks[1].command, "cargo check");
1803 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1804 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1805 }
1806
1807 #[test]
1808 fn hook_name_label_sanitizes_long_multiline_commands() {
1809 let long_cmd = "Goal statement: Achieve >=3x better compression ratio than zstd -19 --long=27 and xz -9e on the Lichess 2024-01 corpus.\n\n3. Validation strategy:\n- (a) Fixed + held-out corpora.";
1813 let escaped = serde_json::to_string(long_cmd).unwrap();
1814 let summary = format!(
1815 r#"{{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{{"command":{escaped},"durationMs":42}}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T12:00:00.000Z","uuid":"goal","toolUseID":"tu_goal","sessionId":"sess-goal","version":"2.1.119"}}"#
1816 );
1817 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1818 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1819
1820 let parsed = parse_jsonl_entries(&path).unwrap();
1821 assert_eq!(parsed.hooks.len(), 1);
1822 let h = &parsed.hooks[0];
1823
1824 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1825 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1826 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1828 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1829 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1830 }
1831
1832 #[test]
1833 fn hook_name_label_preserves_short_commands() {
1834 let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T13:00:00.000Z","uuid":"short","toolUseID":"tu_short","sessionId":"sess-short","version":"2.1.119"}"#;
1835 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1836 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1837
1838 let parsed = parse_jsonl_entries(&path).unwrap();
1839 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1840 }
1841
1842 #[test]
1843 fn parse_handles_both_formats_in_same_file() {
1844 let old_hook = r#"{"type":"attachment","timestamp":"2026-04-23T14:05:05.937Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_old","command":"lint.sh","exitCode":0,"durationMs":90}}"#;
1846 let new_hook = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":5000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T09:06:12.854Z","uuid":"both","toolUseID":"tu_new","sessionId":"sess-both","version":"2.1.119"}"#;
1848 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1849 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1850
1851 let parsed = parse_jsonl_entries(&path).unwrap();
1852 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1853 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1854 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1855 }
1856
1857 #[allow(clippy::too_many_arguments)]
1860 fn entry_for_cost(
1861 model: &str,
1862 input: u64,
1863 output: u64,
1864 cr: u64,
1865 cw_total: u64,
1866 cw_1h: u64,
1867 web: u64,
1868 speed: Option<&str>,
1869 ) -> UsageEntry {
1870 UsageEntry {
1871 session_id: "s".into(),
1872 timestamp_ms: 0,
1873 model: model.into(),
1874 input_tokens: input,
1875 output_tokens: output,
1876 cache_read_tokens: cr,
1877 cache_write_tokens: cw_total,
1878 cache_write_1h_tokens: cw_1h,
1879 web_search_requests: web,
1880 speed: speed.map(|s| s.to_string()),
1881 tool_uses: Vec::new(),
1882 text_chars: 0,
1883 activity: Activity::General,
1884 entrypoint: None,
1885 }
1886 }
1887
1888 #[test]
1889 fn fable_pricing_all_token_classes() {
1890 let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1892 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1893 let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1894 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1895 let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1896 assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1897 let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1898 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1899 let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1900 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1901 }
1902
1903 #[test]
1904 fn fable_has_no_fast_premium() {
1905 let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1908 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1909 }
1910
1911 #[test]
1912 fn fable_context_window_is_natively_1m() {
1913 assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1915 assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1916 assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1917 }
1918
1919 #[test]
1920 fn cost_splits_5m_and_1h_cache_writes() {
1921 let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1923 assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1924 let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1925 assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1926 let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1928 assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1929 }
1930
1931 #[test]
1932 fn cost_adds_flat_web_search_fee() {
1933 let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1935 assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
1936 }
1937
1938 #[test]
1939 fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
1940 let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
1942 assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
1943 let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1944 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1945 let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1947 assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
1948 let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
1950 assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
1951 }
1952
1953 #[test]
1954 fn fast_multiplier_only_for_known_opus() {
1955 assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
1956 assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
1957 assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
1958 assert_eq!(fast_multiplier_for_model("claude-opus-5"), 2.0);
1959 assert_eq!(fast_multiplier_for_model("claude-opus-5[1m]"), 2.0);
1960 assert_eq!(fast_multiplier_for_model("claude-opus-5-5"), 2.0);
1961 assert_eq!(fast_multiplier_for_model("claude-opus-5-5[1m]"), 2.0);
1962 assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
1963 assert_eq!(fast_multiplier_for_model("claude-fable-5-1"), 1.0);
1964 assert_eq!(fast_multiplier_for_model("claude-opus-6"), 1.0);
1966 }
1967
1968 #[test]
1969 fn fable_5_1_reads_cache_at_a_quarter_of_fable_5() {
1970 let read51 = entry_for_cost("claude-fable-5-1", 0, 0, 1_000_000, 0, 0, 0, None);
1973 assert!((cost_for_entry(&read51) - 0.25).abs() < 1e-6);
1974 let read5 = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1975 assert!((cost_for_entry(&read5) - 1.0).abs() < 1e-6);
1976
1977 let input = entry_for_cost("claude-fable-5-1", 1_000_000, 0, 0, 0, 0, 0, None);
1978 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1979 let output = entry_for_cost("claude-fable-5-1", 0, 1_000_000, 0, 0, 0, 0, None);
1980 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1981 let w5m = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 0, 0, None);
1982 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1983 let w1h = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1984 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1985 }
1986
1987 #[test]
1988 fn opus_5_5_is_cheaper_than_opus_5() {
1989 let in55 = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, None);
1992 assert!((cost_for_entry(&in55) - 4.0).abs() < 1e-6);
1993 let out55 = entry_for_cost("claude-opus-5-5", 0, 1_000_000, 0, 0, 0, 0, None);
1994 assert!((cost_for_entry(&out55) - 20.0).abs() < 1e-6);
1995 let read55 = entry_for_cost("claude-opus-5-5", 0, 0, 1_000_000, 0, 0, 0, None);
1996 assert!((cost_for_entry(&read55) - 0.2).abs() < 1e-6);
1997 let w5m = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 0, 0, None);
1998 assert!((cost_for_entry(&w5m) - 5.0).abs() < 1e-6);
1999 let w1h = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2000 assert!((cost_for_entry(&w1h) - 8.0).abs() < 1e-6);
2001
2002 let in55_1m = entry_for_cost("claude-opus-5-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2004 assert!((cost_for_entry(&in55_1m) - 4.0).abs() < 1e-6);
2005 let fast = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2006 assert!((cost_for_entry(&fast) - 8.0).abs() < 1e-6);
2007
2008 let in5 = entry_for_cost("claude-opus-5", 1_000_000, 0, 0, 0, 0, 0, None);
2010 assert!((cost_for_entry(&in5) - 5.0).abs() < 1e-6);
2011 assert_eq!(pricing_for_model("opus").input, 5.0);
2012 assert_eq!(pricing_for_model("opus").cache_read, 0.5);
2013 }
2014
2015 #[test]
2016 fn sonnet_5_is_cheaper_than_sonnet_4_6() {
2017 let in5 = entry_for_cost("claude-sonnet-5", 1_000_000, 0, 0, 0, 0, 0, None);
2020 assert!((cost_for_entry(&in5) - 2.0).abs() < 1e-6);
2021 let out5 = entry_for_cost("claude-sonnet-5", 0, 1_000_000, 0, 0, 0, 0, None);
2022 assert!((cost_for_entry(&out5) - 10.0).abs() < 1e-6);
2023 let read5 = entry_for_cost("claude-sonnet-5", 0, 0, 1_000_000, 0, 0, 0, None);
2024 assert!((cost_for_entry(&read5) - 0.2).abs() < 1e-6);
2025 let w5m = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 0, 0, None);
2026 assert!((cost_for_entry(&w5m) - 2.5).abs() < 1e-6);
2027 let w1h = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2028 assert!((cost_for_entry(&w1h) - 4.0).abs() < 1e-6);
2029
2030 let in46 = entry_for_cost("claude-sonnet-4-6", 1_000_000, 0, 0, 0, 0, 0, None);
2031 assert!((cost_for_entry(&in46) - 3.0).abs() < 1e-6);
2032
2033 let in5_1m = entry_for_cost("claude-sonnet-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2035 assert!((cost_for_entry(&in5_1m) - 2.0).abs() < 1e-6);
2036 }
2037
2038 #[test]
2039 fn haiku_5_5_is_a_tenth_of_haiku_4_5() {
2040 for id in ["claude-haiku-5-5", "claude-haiku-5-5-20261007"] {
2042 let p = pricing_for_model(id);
2043 assert_eq!(p.input, 0.1, "{id}");
2044 assert_eq!(p.output, 0.5, "{id}");
2045 assert_eq!(p.cache_read, 0.01, "{id}");
2046 assert_eq!(p.cache_write, 0.125, "{id}");
2047 assert_eq!(p.cache_write_1h, 0.2, "{id}");
2048 }
2049 for id in ["claude-haiku-4-5", "claude-haiku-4-5-20251001", "haiku"] {
2051 let p = pricing_for_model(id);
2052 assert_eq!(p.input, 1.0, "{id}");
2053 assert_eq!(p.output, 5.0, "{id}");
2054 assert_eq!(p.cache_read, 0.1, "{id}");
2055 }
2056 }
2057
2058 #[test]
2059 fn haiku_5_5_long_prompts_bill_the_higher_tier() {
2060 let model = "claude-haiku-5-5";
2061 let at = entry_for_cost(model, 50_000, 0, 30_000, 20_000, 0, 0, None);
2063 let want_at = (50_000.0 * 0.1 + 30_000.0 * 0.01 + 20_000.0 * 0.125) / 1e6;
2064 assert!((cost_for_entry(&at) - want_at).abs() < 1e-9);
2065 let over = entry_for_cost(model, 50_000, 0, 30_000, 20_001, 0, 0, None);
2066 let want_over = (50_000.0 * 0.5 + 30_000.0 * 0.05 + 20_001.0 * 0.625) / 1e6;
2067 assert!((cost_for_entry(&over) - want_over).abs() < 1e-9);
2068
2069 let out = entry_for_cost(model, 100_001, 1_000_000, 0, 0, 0, 0, None);
2071 assert!((cost_for_entry(&out) - (100_001.0 * 0.5 + 2_500_000.0) / 1e6).abs() < 1e-9);
2072 let w1h = entry_for_cost(model, 0, 0, 0, 200_000, 200_000, 0, None);
2073 assert!((cost_for_entry(&w1h) - 0.2).abs() < 1e-9);
2074
2075 let h45 = entry_for_cost("claude-haiku-4-5", 200_000, 0, 0, 0, 0, 0, None);
2077 assert!((cost_for_entry(&h45) - 0.2).abs() < 1e-9);
2078 }
2079
2080 #[test]
2081 fn haiku_5_5_context_window_is_natively_1m() {
2082 assert_eq!(context_window_for_model("claude-haiku-5-5"), 1_000_000);
2083 assert_eq!(context_window_for_model("claude-haiku-4-5"), 200_000);
2084 assert_eq!(context_window_for_model("claude-haiku-4-5-20251001"), 200_000);
2085 }
2086
2087 #[test]
2088 fn unknown_models_keep_the_conservative_sonnet_rate() {
2089 for id in ["", "<unknown>", "sonnet", "claude-3-5-sonnet-20241022"] {
2092 let p = pricing_for_model(id);
2093 assert_eq!(p.input, 3.0, "{id} should price at the Sonnet default");
2094 }
2095 assert_eq!(pricing_for_model("fable").cache_read, 1.0);
2098 }
2099
2100 #[test]
2101 fn family_version_reads_minor_and_ignores_date_snapshots() {
2102 assert_eq!(family_version("claude-fable-5-1", "fable"), Some((5, 1)));
2103 assert_eq!(family_version("claude-fable-5", "fable"), Some((5, 0)));
2104 assert_eq!(family_version("claude-sonnet-5", "sonnet"), Some((5, 0)));
2105 assert_eq!(family_version("claude-sonnet-4-6", "sonnet"), Some((4, 6)));
2106 assert_eq!(family_version("claude-opus-5-5", "opus"), Some((5, 5)));
2107 assert_eq!(family_version("claude-opus-5-5[1m]", "opus"), Some((5, 5)));
2108 assert_eq!(
2109 family_version("claude-haiku-4-5-20251001", "haiku"),
2110 Some((4, 5))
2111 );
2112 assert_eq!(
2114 family_version("claude-sonnet-5-20260701", "sonnet"),
2115 Some((5, 0))
2116 );
2117 assert_eq!(family_version("fable", "fable"), None);
2118 assert_eq!(family_version("claude-opus-4-8", "sonnet"), None);
2119 assert_eq!(family_version("claude-3-5-sonnet-20241022", "sonnet"), None);
2122 }
2123
2124 #[test]
2125 fn parse_extracts_cache_split_speed_and_web_search() {
2126 let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_1","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":673,"cache_read_input_tokens":0,"cache_creation_input_tokens":40702,"cache_creation":{"ephemeral_5m_input_tokens":700,"ephemeral_1h_input_tokens":40002},"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5},"speed":"fast"}}}"#;
2127 let (_tmp, path) = write_jsonl(&[line]);
2128 let entries = parse_jsonl_entries(&path).unwrap().entries;
2129 assert_eq!(entries.len(), 1);
2130 let e = &entries[0];
2131 assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
2132 assert_eq!(e.cache_write_1h_tokens, 40002);
2133 assert_eq!(e.web_search_requests, 2);
2134 assert_eq!(e.speed.as_deref(), Some("fast"));
2135 }
2136
2137 #[test]
2138 fn parse_cache_write_takes_max_of_flat_and_split() {
2139 let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_2","model":"claude-opus-4-8","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":500,"ephemeral_1h_input_tokens":900}}}}"#;
2141 let (_tmp, path) = write_jsonl(&[line]);
2142 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2143 assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
2144 assert_eq!(e.cache_write_1h_tokens, 900);
2145 }
2146
2147 #[test]
2148 fn parse_missing_nested_cache_falls_back_to_flat() {
2149 let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
2151 let (_tmp, path) = write_jsonl(&[&line]);
2152 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2153 assert_eq!(e.cache_write_tokens, 30000);
2154 assert_eq!(e.cache_write_1h_tokens, 0);
2155 assert_eq!(e.web_search_requests, 0);
2156 assert_eq!(e.speed, None);
2157 }
2158
2159 #[test]
2160 fn reduce_session_prices_1h_cache_premium() {
2161 let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2163 let u = reduce_session(&[e], "", "sid").unwrap();
2164 assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
2165 }
2166}