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 {
239 let native_1m = model.contains("fable")
240 || is_haiku_5_5(model)
241 || matches!(family_version(model, "opus"), Some(v) if v >= (5, 0))
242 || matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0));
243 if model.contains("[1m]") || native_1m {
244 1_000_000
245 } else {
246 200_000
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct UsageEntry {
268 pub session_id: String,
269 pub timestamp_ms: u64,
271 pub model: String,
272 pub input_tokens: u64,
273 pub output_tokens: u64,
274 pub cache_read_tokens: u64,
275 pub cache_write_tokens: u64,
280 #[serde(default)]
284 pub cache_write_1h_tokens: u64,
285 #[serde(default)]
288 pub web_search_requests: u64,
289 #[serde(default)]
293 pub speed: Option<String>,
294 pub tool_uses: Vec<String>,
298 pub text_chars: u32,
301 pub activity: Activity,
304 pub entrypoint: Option<String>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(rename_all = "camelCase")]
317pub struct HookExecution {
318 pub session_id: String,
319 pub timestamp_ms: u64,
320 pub hook_event: String,
322 pub hook_name: String,
324 pub command: String,
326 pub exit_code: i32,
327 pub duration_ms: u64,
328 pub tool_use_id: Option<String>,
330}
331
332#[derive(Default)]
334pub struct ParsedJsonl {
335 pub entries: Vec<UsageEntry>,
336 pub hooks: Vec<HookExecution>,
337}
338
339#[derive(Debug, Clone, Serialize, Default)]
341#[serde(rename_all = "camelCase")]
342pub struct SessionUsage {
343 pub model: String,
344 pub input_tokens: u64,
345 pub output_tokens: u64,
346 pub cache_read_tokens: u64,
347 pub cache_write_tokens: u64,
348 pub total_tokens: u64,
349 pub estimated_cost_usd: f64,
350 pub context_window_size: u64,
351 pub context_window_used_pct: f64,
352 pub current_context_tokens: u64,
354 pub total_context_sent: u64,
356 pub cache_hit_pct: f64,
358 pub message_count: u64,
359 pub session_id: String,
360}
361
362#[derive(Deserialize)]
364#[serde(rename_all = "camelCase")]
365struct JsonlRaw {
366 #[serde(rename = "type")]
367 entry_type: Option<String>,
368 timestamp: Option<String>,
369 message: Option<MessageData>,
370 attachment: Option<AttachmentData>,
371 subtype: Option<String>,
373 #[allow(dead_code)]
374 hook_count: Option<usize>,
375 hook_infos: Option<Vec<HookInfoEntry>>,
376 hook_errors: Option<Vec<serde_json::Value>>,
377 prevented_continuation: Option<bool>,
378 #[serde(rename = "toolUseID")]
379 tool_use_id: Option<String>,
380 entrypoint: Option<String>,
383}
384
385#[derive(Deserialize)]
386struct MessageData {
387 id: Option<String>,
388 model: Option<String>,
389 usage: Option<UsageData>,
390 #[serde(default)]
395 content: Option<serde_json::Value>,
396}
397
398#[derive(Deserialize, Default)]
399struct UsageData {
400 input_tokens: Option<u64>,
401 output_tokens: Option<u64>,
402 cache_read_input_tokens: Option<u64>,
403 cache_creation_input_tokens: Option<u64>,
406 cache_creation: Option<CacheCreation>,
408 server_tool_use: Option<ServerToolUse>,
410 speed: Option<String>,
412}
413
414#[derive(Deserialize, Default)]
416struct CacheCreation {
417 ephemeral_5m_input_tokens: Option<u64>,
418 ephemeral_1h_input_tokens: Option<u64>,
419}
420
421#[derive(Deserialize, Default)]
424struct ServerToolUse {
425 web_search_requests: Option<u64>,
426}
427
428#[derive(Deserialize)]
430#[serde(rename_all = "camelCase")]
431struct HookInfoEntry {
432 command: Option<String>,
433 duration_ms: Option<u64>,
434}
435
436#[derive(Deserialize)]
438#[serde(rename_all = "camelCase")]
439struct AttachmentData {
440 #[serde(rename = "type")]
441 attachment_type: Option<String>,
442 hook_name: Option<String>,
443 hook_event: Option<String>,
444 command: Option<String>,
445 exit_code: Option<i32>,
446 duration_ms: Option<u64>,
447 #[serde(rename = "toolUseID")]
448 tool_use_id: Option<String>,
449}
450
451#[derive(Deserialize)]
453#[serde(rename_all = "camelCase")]
454struct ActiveSession {
455 session_id: String,
456 cwd: String,
457 started_at: u64,
458}
459
460use super::MAX_JSONL_BYTES;
467
468pub fn encode_project_path(project_path: &str) -> String {
478 let normalized = project_path.replace('/', "\\");
479 let mut result = String::new();
480 let mut chars = normalized.chars().peekable();
481
482 while let Some(ch) = chars.next() {
483 if ch == ':' {
484 if chars.peek() == Some(&'\\') {
485 chars.next();
486 result.push_str("--");
487 } else {
488 result.push('-');
489 }
490 } else if ch == '\\' {
491 result.push('-');
492 } else {
493 result.push(ch);
494 }
495 }
496
497 while result.ends_with('-') {
498 result.pop();
499 }
500
501 result
502}
503
504struct AssistantContent {
506 tool_uses: Vec<String>,
507 bash_cmds: Vec<String>,
508 text_chars: u32,
509}
510
511fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
515 let mut out = AssistantContent {
516 tool_uses: Vec::new(),
517 bash_cmds: Vec::new(),
518 text_chars: 0,
519 };
520 let arr = match content.and_then(|v| v.as_array()) {
521 Some(a) => a,
522 None => return out,
523 };
524
525 for block in arr {
526 let block_type = match block.get("type").and_then(|v| v.as_str()) {
527 Some(t) => t,
528 None => continue,
529 };
530 match block_type {
531 "tool_use" => {
532 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
533 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
536 if let Some(cmd) = block
537 .get("input")
538 .and_then(|i| i.get("command"))
539 .and_then(|c| c.as_str())
540 {
541 out.bash_cmds.push(cmd.to_string());
542 }
543 }
544 out.tool_uses.push(name.to_string());
545 }
546 }
547 "text" => {
548 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
549 out.text_chars = out
552 .text_chars
553 .saturating_add(text.chars().count() as u32);
554 }
555 }
556 _ => {}
557 }
558 }
559
560 out
561}
562
563fn sanitize_hook_command_label(cmd: &str) -> String {
582 const MAX_LEN: usize = 64;
583 let first_line = cmd.lines().next().unwrap_or("").trim();
584 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
585 if collapsed.chars().count() <= MAX_LEN {
586 return collapsed;
587 }
588 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
589 format!("{truncated}…")
590}
591
592fn snake_to_pascal(s: &str) -> String {
594 s.split('_')
595 .filter(|w| !w.is_empty())
596 .map(|w| {
597 let mut c = w.chars();
598 match c.next() {
599 Some(first) => {
600 let mut out = first.to_uppercase().to_string();
601 out.extend(c);
602 out
603 }
604 None => String::new(),
605 }
606 })
607 .collect()
608}
609
610fn extract_user_text(content: &serde_json::Value) -> Option<String> {
613 match content {
614 serde_json::Value::String(s) => {
615 if s.is_empty() {
616 None
617 } else {
618 Some(s.clone())
619 }
620 }
621 serde_json::Value::Array(arr) => {
622 let mut parts: Vec<&str> = Vec::new();
623 for item in arr {
624 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
625 if t == "text" {
626 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
627 if !text.is_empty() {
628 parts.push(text);
629 }
630 }
631 }
632 }
633 if parts.is_empty() {
634 None
635 } else {
636 Some(parts.join(" "))
637 }
638 }
639 _ => None,
640 }
641}
642
643struct EntryAccumulator {
646 out_idx: usize,
647 bash_cmds: Vec<String>,
648 user_text_at_first: Option<String>,
649}
650
651pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
675 let meta = fs::metadata(path)?;
676 if meta.len() > MAX_JSONL_BYTES {
677 tracing::warn!(
678 path = %path.display(),
679 size = meta.len(),
680 "skipping JSONL larger than {}MB",
681 MAX_JSONL_BYTES / 1024 / 1024
682 );
683 return Ok(ParsedJsonl::default());
684 }
685
686 let file = fs::File::open(path)?;
687 let reader = BufReader::new(file);
688
689 let session_id = path
690 .file_stem()
691 .and_then(|s| s.to_str())
692 .unwrap_or("")
693 .to_string();
694
695 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
696 let mut out: Vec<UsageEntry> = Vec::new();
697 let mut hooks: Vec<HookExecution> = Vec::new();
698 let mut last_user_text: Option<String> = None;
699
700 for line in reader.lines() {
701 let line = match line {
702 Ok(l) => l,
703 Err(_) => continue,
704 };
705 let line = line.trim();
706 if line.is_empty() {
707 continue;
708 }
709
710 let raw: JsonlRaw = match serde_json::from_str(line) {
711 Ok(r) => r,
712 Err(_) => continue,
713 };
714
715 let entry_type = raw.entry_type.as_deref();
716
717 if entry_type == Some("user") {
720 if let Some(msg) = raw.message.as_ref() {
721 if let Some(content) = msg.content.as_ref() {
722 if let Some(text) = extract_user_text(content) {
723 last_user_text = Some(normalize_user_text(&text));
724 }
725 }
726 }
727 continue;
728 }
729
730 if entry_type == Some("attachment") {
732 if let Some(att) = raw.attachment {
733 if att.attachment_type.as_deref() == Some("hook_success") {
734 let timestamp_ms = raw
735 .timestamp
736 .as_deref()
737 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
738 .map(|dt| dt.timestamp_millis().max(0) as u64)
739 .unwrap_or(0);
740 hooks.push(HookExecution {
741 session_id: session_id.clone(),
742 timestamp_ms,
743 hook_event: att.hook_event.unwrap_or_default(),
744 hook_name: att.hook_name.unwrap_or_default(),
745 command: att.command.unwrap_or_default(),
746 exit_code: att.exit_code.unwrap_or(0),
747 duration_ms: att.duration_ms.unwrap_or(0),
748 tool_use_id: att.tool_use_id,
749 });
750 }
751 }
752 continue;
753 }
754
755 if entry_type == Some("system") {
758 if let Some(subtype) = raw.subtype.as_deref() {
759 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
760 let hook_event = snake_to_pascal(event_snake);
761 let timestamp_ms = raw
762 .timestamp
763 .as_deref()
764 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
765 .map(|dt| dt.timestamp_millis().max(0) as u64)
766 .unwrap_or(0);
767 let had_errors = raw
768 .hook_errors
769 .as_ref()
770 .is_some_and(|e| !e.is_empty());
771 let prevented = raw.prevented_continuation.unwrap_or(false);
772 if let Some(infos) = raw.hook_infos {
773 for info in infos {
774 let cmd = info.command.unwrap_or_default();
775 let hook_name =
776 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
777 hooks.push(HookExecution {
778 session_id: session_id.clone(),
779 timestamp_ms,
780 hook_event: hook_event.clone(),
781 hook_name,
782 command: cmd,
783 exit_code: if had_errors || prevented { 1 } else { 0 },
784 duration_ms: info.duration_ms.unwrap_or(0),
785 tool_use_id: raw.tool_use_id.clone(),
786 });
787 }
788 }
789 }
790 }
791 continue;
792 }
793
794 if entry_type != Some("assistant") {
795 continue;
796 }
797
798 let msg = match raw.message {
799 Some(m) => m,
800 None => continue,
801 };
802 let mid = match msg.id {
803 Some(id) => id,
804 None => continue,
805 };
806
807 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
808
809 if let Some(acc) = accums.get_mut(&mid) {
812 let entry = &mut out[acc.out_idx];
813 entry
814 .tool_uses
815 .append(&mut content_parsed.tool_uses);
816 entry.text_chars = entry
817 .text_chars
818 .saturating_add(content_parsed.text_chars);
819 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
820 continue;
821 }
822
823 let model = msg.model.unwrap_or_default();
829 if model == "<synthetic>" {
830 continue;
831 }
832
833 let usage = msg.usage.unwrap_or_default();
834 let timestamp_ms = raw
835 .timestamp
836 .as_deref()
837 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
838 .map(|dt| dt.timestamp_millis().max(0) as u64)
839 .unwrap_or(0);
840
841 let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
845 let (eph_5m, eph_1h) = usage
846 .cache_creation
847 .as_ref()
848 .map(|c| {
849 (
850 c.ephemeral_5m_input_tokens.unwrap_or(0),
851 c.ephemeral_1h_input_tokens.unwrap_or(0),
852 )
853 })
854 .unwrap_or((0, 0));
855 let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
856 let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
857 let web_search_requests = usage
858 .server_tool_use
859 .as_ref()
860 .map(|s| s.web_search_requests.unwrap_or(0))
861 .unwrap_or(0);
862 let speed = usage.speed.clone();
863
864 accums.insert(
868 mid.clone(),
869 EntryAccumulator {
870 out_idx: out.len(),
871 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
872 user_text_at_first: last_user_text.clone(),
873 },
874 );
875 out.push(UsageEntry {
876 session_id: session_id.clone(),
877 timestamp_ms,
878 model,
879 input_tokens: usage.input_tokens.unwrap_or(0),
880 output_tokens: usage.output_tokens.unwrap_or(0),
881 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
882 cache_write_tokens,
883 cache_write_1h_tokens,
884 web_search_requests,
885 speed,
886 tool_uses: content_parsed.tool_uses,
887 text_chars: content_parsed.text_chars,
888 activity: Activity::General,
889 entrypoint: raw.entrypoint.clone(),
890 });
891 }
892
893 for acc in accums.values() {
896 let entry = &mut out[acc.out_idx];
897 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
898 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
899 entry.activity = classify(
900 &tool_refs,
901 &bash_refs,
902 acc.user_text_at_first.as_deref(),
903 entry.text_chars,
904 );
905 }
906
907 Ok(ParsedJsonl {
908 entries: out,
909 hooks,
910 })
911}
912
913pub fn find_current_session_jsonl(
917 config_dir: &str,
918 project_path: &str,
919 spawned_at: SystemTime,
920) -> Option<PathBuf> {
921 let config = Path::new(config_dir);
922 let encoded = encode_project_path(project_path);
923 let projects_dir = config.join("projects").join(&encoded);
924
925 if !projects_dir.exists() {
926 return None;
927 }
928
929 let sessions_dir = config.join("sessions");
932 if sessions_dir.exists() {
933 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
934 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
935 if jsonl_path.exists() {
936 return Some(jsonl_path);
937 }
938 }
939 }
940
941 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
946
947 if let Ok(entries) = fs::read_dir(&projects_dir) {
948 for entry in entries.flatten() {
949 let path = entry.path();
950 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
951 continue;
952 }
953 if let Ok(meta) = path.metadata() {
954 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
955 if created >= spawned_at {
956 candidates.push((path, created));
957 }
958 }
959 }
960 }
961
962 candidates.sort_by_key(|a| a.1);
964 candidates.into_iter().next().map(|(p, _)| p)
965}
966
967fn find_active_session_id(
970 sessions_dir: &Path,
971 project_path: &str,
972 spawned_at: SystemTime,
973) -> Option<String> {
974 let spawned_ms = spawned_at
975 .duration_since(SystemTime::UNIX_EPOCH)
976 .ok()?
977 .as_millis() as u64;
978
979 let threshold_ms = spawned_ms.saturating_sub(10_000);
981
982 let normalized_project = normalize_path(project_path);
983
984 let mut best: Option<(String, u64)> = None;
985
986 if let Ok(entries) = fs::read_dir(sessions_dir) {
987 for entry in entries.flatten() {
988 let path = entry.path();
989 if path.extension().and_then(|e| e.to_str()) != Some("json") {
990 continue;
991 }
992 if let Ok(content) = fs::read_to_string(&path) {
993 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
994 let session_cwd = normalize_path(&session.cwd);
995 if session_cwd == normalized_project && session.started_at >= threshold_ms {
996 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
1002 best = Some((session.session_id, session.started_at));
1003 }
1004 }
1005 }
1006 }
1007 }
1008 }
1009
1010 best.map(|(id, _)| id)
1011}
1012
1013fn normalize_path(p: &str) -> String {
1015 p.replace('\\', "/")
1016 .trim_end_matches('/')
1017 .to_lowercase()
1018}
1019
1020pub fn reduce_session(
1030 entries: &[UsageEntry],
1031 launched_model: &str,
1032 session_id_hint: &str,
1033) -> Option<SessionUsage> {
1034 if entries.is_empty() {
1035 return None;
1036 }
1037
1038 let mut input_tokens = 0u64;
1039 let mut output_tokens = 0u64;
1040 let mut cache_read = 0u64;
1041 let mut cache_write = 0u64;
1042 let mut model_counts: HashMap<&str, u64> = HashMap::new();
1043
1044 for e in entries {
1045 input_tokens += e.input_tokens;
1046 output_tokens += e.output_tokens;
1047 cache_read += e.cache_read_tokens;
1048 cache_write += e.cache_write_tokens;
1049 if !e.model.is_empty() {
1050 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
1051 }
1052 }
1053
1054 let primary_model = model_counts
1055 .into_iter()
1056 .max_by_key(|(_, c)| *c)
1057 .map(|(m, _)| m.to_string())
1058 .unwrap_or_else(|| {
1059 entries
1060 .last()
1061 .map(|e| e.model.clone())
1062 .unwrap_or_default()
1063 });
1064
1065 let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
1069
1070 let context_model = if launched_model.is_empty() {
1071 &primary_model
1072 } else {
1073 launched_model
1074 };
1075 let peak_context = entries
1079 .iter()
1080 .map(|e| e.input_tokens + e.cache_read_tokens + e.cache_write_tokens)
1081 .max()
1082 .unwrap_or(0);
1083 let mut context_window = context_window_for_model(context_model);
1084 if peak_context > context_window {
1085 context_window = [200_000, 1_000_000]
1086 .into_iter()
1087 .find(|&w| peak_context <= w)
1088 .unwrap_or(peak_context);
1089 }
1090
1091 let last = entries.last().unwrap();
1092 let last_context_used =
1093 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
1094 let context_pct = if context_window > 0 {
1095 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
1096 } else {
1097 0.0
1098 };
1099
1100 let total = input_tokens + output_tokens + cache_read + cache_write;
1101 let total_context_sent = input_tokens + cache_read + cache_write;
1102
1103 let cache_denom = input_tokens + cache_read + cache_write;
1108 let cache_hit_pct = if cache_denom > 0 {
1109 (cache_read as f64 / cache_denom as f64) * 100.0
1110 } else {
1111 0.0
1112 };
1113
1114 let session_id = entries
1115 .first()
1116 .map(|e| e.session_id.clone())
1117 .filter(|s| !s.is_empty())
1118 .unwrap_or_else(|| session_id_hint.to_string());
1119
1120 Some(SessionUsage {
1121 model: primary_model,
1122 input_tokens,
1123 output_tokens,
1124 cache_read_tokens: cache_read,
1125 cache_write_tokens: cache_write,
1126 total_tokens: total,
1127 estimated_cost_usd: estimated_cost,
1128 context_window_size: context_window,
1129 context_window_used_pct: context_pct,
1130 current_context_tokens: last_context_used,
1131 total_context_sent,
1132 cache_hit_pct,
1133 message_count: entries.len() as u64,
1134 session_id,
1135 })
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use std::io::Write;
1142 use tempfile::TempDir;
1143
1144 #[test]
1145 fn test_encode_project_path_drive_root() {
1146 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1147 }
1148
1149 #[test]
1150 fn test_encode_project_path_deep() {
1151 assert_eq!(
1152 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1153 "C--Users-Seven-Desktop-SF-project"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_encode_project_path_forward_slashes() {
1159 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1160 }
1161
1162 #[test]
1163 fn test_encode_project_path_trailing_slash() {
1164 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1165 }
1166
1167 #[test]
1168 fn test_encode_project_path_macos() {
1169 assert_eq!(
1170 encode_project_path("/Users/seven/Loom"),
1171 "-Users-seven-Loom"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_encode_project_path_linux() {
1177 assert_eq!(
1178 encode_project_path("/home/alice/projects/foo"),
1179 "-home-alice-projects-foo"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_encode_project_path_unix_trailing_slash() {
1185 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1186 }
1187
1188 #[test]
1189 fn test_normalize_path() {
1190 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1191 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1192 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1193 }
1194
1195 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1196 let tmp = TempDir::new().unwrap();
1197 let path = tmp.path().join("abc123.jsonl");
1198 let mut f = fs::File::create(&path).unwrap();
1199 for l in lines {
1200 writeln!(f, "{}", l).unwrap();
1201 }
1202 (tmp, path)
1203 }
1204
1205 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1206 format!(
1207 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}}}}}}}"#
1208 )
1209 }
1210
1211 #[test]
1212 fn parse_deduplicates_repeated_message_ids() {
1213 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1216 let a1_dup = a1.clone();
1217 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1218 let a2_dup = a2.clone();
1219 let a2_dup2 = a2.clone();
1220 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1221
1222 let entries = parse_jsonl_entries(&path).unwrap().entries;
1223 assert_eq!(entries.len(), 2, "should dedup by message.id");
1224 assert_eq!(entries[0].input_tokens, 15);
1225 assert_eq!(entries[0].output_tokens, 1463);
1226 assert_eq!(entries[1].input_tokens, 10);
1227 }
1228
1229 #[test]
1230 fn parse_captures_entrypoint_from_assistant_row() {
1231 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}}}"#;
1235 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}}}"#;
1236 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}}}"#;
1237 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1238
1239 let entries = parse_jsonl_entries(&path).unwrap().entries;
1240 assert_eq!(entries.len(), 3);
1241 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1242 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1243 assert_eq!(entries[2].entrypoint, None);
1244 }
1245
1246 #[test]
1247 fn parse_skips_non_assistant_entries() {
1248 let lines = [
1249 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1250 r#"{"type":"permission-mode"}"#.to_string(),
1251 r#"{"type":"file-history-snapshot"}"#.to_string(),
1252 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1253 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1254 ];
1255 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1256 let (_tmp, path) = write_jsonl(&line_refs);
1257
1258 let entries = parse_jsonl_entries(&path).unwrap().entries;
1259 assert_eq!(entries.len(), 1);
1260 assert_eq!(entries[0].model, "claude-sonnet-4-6");
1261 }
1262
1263 #[test]
1264 fn parse_tolerates_malformed_lines_and_empty_lines() {
1265 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1266 let lines = vec![
1267 "",
1268 "not json at all",
1269 r#"{"type":"assistant","message":null}"#,
1270 r#"{"type":"assistant","message":{"id":null}}"#,
1271 &good,
1272 " ",
1273 r#"{"broken":"trailing"#,
1274 ];
1275 let (_tmp, path) = write_jsonl(&lines);
1276
1277 let entries = parse_jsonl_entries(&path).unwrap().entries;
1278 assert_eq!(entries.len(), 1);
1279 }
1280
1281 #[test]
1282 fn parse_extracts_session_id_from_file_stem() {
1283 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1284 let (_tmp, path) = write_jsonl(&[&line]);
1285 let entries = parse_jsonl_entries(&path).unwrap().entries;
1286 assert_eq!(entries[0].session_id, "abc123");
1287 }
1288
1289 #[test]
1290 fn parse_parses_iso_timestamps() {
1291 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1292 let (_tmp, path) = write_jsonl(&[&line]);
1293 let entries = parse_jsonl_entries(&path).unwrap().entries;
1294 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1296 }
1297
1298 #[test]
1299 fn parse_skips_synthetic_model_rows() {
1300 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1303 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}}}"#;
1304 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1305 let entries = parse_jsonl_entries(&path).unwrap().entries;
1306 assert_eq!(entries.len(), 1);
1307 assert_eq!(entries[0].model, "claude-opus-4-6");
1308 }
1309
1310 #[test]
1311 fn parse_handles_missing_usage_fields() {
1312 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1313 let (_tmp, path) = write_jsonl(&[line]);
1314 let entries = parse_jsonl_entries(&path).unwrap().entries;
1315 assert_eq!(entries.len(), 1);
1316 assert_eq!(entries[0].input_tokens, 0);
1317 assert_eq!(entries[0].cache_read_tokens, 0);
1318 }
1319
1320 #[test]
1321 fn reduce_computes_primary_model_by_mode() {
1322 let e = |m: &str| UsageEntry {
1323 session_id: "s".into(),
1324 timestamp_ms: 0,
1325 model: m.into(),
1326 input_tokens: 1,
1327 output_tokens: 1,
1328 cache_read_tokens: 0,
1329 cache_write_tokens: 0,
1330 cache_write_1h_tokens: 0,
1331 web_search_requests: 0,
1332 speed: None,
1333 tool_uses: Vec::new(),
1334 text_chars: 0,
1335 activity: Activity::General,
1336 entrypoint: None,
1337 };
1338 let entries = vec![
1339 e("claude-opus-4-6"),
1340 e("claude-opus-4-6"),
1341 e("claude-opus-4-6"),
1342 e("claude-sonnet-4-6"),
1343 ];
1344 let u = reduce_session(&entries, "", "sid").unwrap();
1345 assert_eq!(u.model, "claude-opus-4-6");
1346 assert_eq!(u.message_count, 4);
1347 }
1348
1349 #[test]
1350 fn reduce_uses_launched_model_for_context_window() {
1351 let entries = vec![UsageEntry {
1352 session_id: "s".into(),
1353 timestamp_ms: 0,
1354 model: "claude-opus-4-6".into(),
1356 input_tokens: 1000,
1357 output_tokens: 0,
1358 cache_read_tokens: 0,
1359 cache_write_tokens: 0,
1360 cache_write_1h_tokens: 0,
1361 web_search_requests: 0,
1362 speed: None,
1363 tool_uses: Vec::new(),
1364 text_chars: 0,
1365 activity: Activity::General,
1366 entrypoint: None,
1367 }];
1368 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1369 let without = reduce_session(&entries, "", "sid").unwrap();
1370 assert_eq!(with_1m.context_window_size, 1_000_000);
1371 assert_eq!(without.context_window_size, 200_000);
1372 }
1373
1374 #[test]
1375 fn bare_opus_and_sonnet_5_plus_are_natively_1m() {
1376 for m in [
1377 "claude-opus-5-5",
1378 "claude-opus-5",
1379 "claude-sonnet-5",
1380 "claude-sonnet-5-5",
1381 "claude-opus-5-5[1m]",
1382 ] {
1383 assert_eq!(context_window_for_model(m), 1_000_000, "{m}");
1384 }
1385 for m in ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"] {
1386 assert_eq!(context_window_for_model(m), 200_000, "{m}");
1387 }
1388 }
1389
1390 #[test]
1391 fn reduce_widens_window_to_hold_peak_context() {
1392 let mk = |input: u64, cr: u64| UsageEntry {
1393 session_id: "s".into(),
1394 timestamp_ms: 0,
1395 model: "claude-opus-4-8".into(),
1396 input_tokens: input,
1397 output_tokens: 0,
1398 cache_read_tokens: cr,
1399 cache_write_tokens: 0,
1400 cache_write_1h_tokens: 0,
1401 web_search_requests: 0,
1402 speed: None,
1403 tool_uses: Vec::new(),
1404 text_chars: 0,
1405 activity: Activity::General,
1406 entrypoint: None,
1407 };
1408 let entries = vec![mk(10, 250_000), mk(10, 99_990)];
1410 let u = reduce_session(&entries, "claude-opus-4-8", "sid").unwrap();
1411 assert_eq!(u.context_window_size, 1_000_000);
1412 assert_eq!(u.current_context_tokens, 100_000);
1413 assert!((u.context_window_used_pct - 10.0).abs() < 0.01);
1414 }
1415
1416 #[test]
1417 fn reduce_context_pct_uses_last_entry_only() {
1418 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1420 session_id: "s".into(),
1421 timestamp_ms: 0,
1422 model: "claude-opus-4-6".into(),
1423 input_tokens: input,
1424 output_tokens: 0,
1425 cache_read_tokens: cr,
1426 cache_write_tokens: cw,
1427 cache_write_1h_tokens: 0,
1428 web_search_requests: 0,
1429 speed: None,
1430 tool_uses: Vec::new(),
1431 text_chars: 0,
1432 activity: Activity::General,
1433 entrypoint: None,
1434 };
1435 let entries = vec![
1436 mk(1000, 0, 0),
1437 mk(500, 0, 0),
1438 mk(5, 160_000, 0), ];
1440 let u = reduce_session(&entries, "", "sid").unwrap();
1441 assert_eq!(u.current_context_tokens, 160_005);
1442 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1443 }
1444
1445 #[test]
1446 fn reduce_cache_hit_pct() {
1447 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1448 session_id: "s".into(),
1449 timestamp_ms: 0,
1450 model: "claude-opus-4-6".into(),
1451 input_tokens: input,
1452 output_tokens: 1,
1453 cache_read_tokens: cr,
1454 cache_write_tokens: cw,
1455 cache_write_1h_tokens: 0,
1456 web_search_requests: 0,
1457 speed: None,
1458 tool_uses: Vec::new(),
1459 text_chars: 0,
1460 activity: Activity::General,
1461 entrypoint: None,
1462 };
1463 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1465 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1466
1467 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1469 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1470
1471 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1473 assert_eq!(u.cache_hit_pct, 0.0);
1474 }
1475
1476 #[test]
1477 fn reduce_total_context_sent_is_cumulative() {
1478 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1479 session_id: "s".into(),
1480 timestamp_ms: 0,
1481 model: "claude-opus-4-6".into(),
1482 input_tokens: input,
1483 output_tokens: 0,
1484 cache_read_tokens: cr,
1485 cache_write_tokens: cw,
1486 cache_write_1h_tokens: 0,
1487 web_search_requests: 0,
1488 speed: None,
1489 tool_uses: Vec::new(),
1490 text_chars: 0,
1491 activity: Activity::General,
1492 entrypoint: None,
1493 };
1494 let u = reduce_session(
1495 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1496 "",
1497 "sid",
1498 )
1499 .unwrap();
1500 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1501 }
1502
1503 #[test]
1504 fn reduce_cost_matches_opus_pricing() {
1505 let entries = vec![UsageEntry {
1507 session_id: "s".into(),
1508 timestamp_ms: 0,
1509 model: "claude-opus-4-6".into(),
1510 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,
1515 web_search_requests: 0,
1516 speed: None,
1517 tool_uses: Vec::new(),
1518 text_chars: 0,
1519 activity: Activity::General,
1520 entrypoint: None,
1521 }];
1522 let u = reduce_session(&entries, "", "sid").unwrap();
1523 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1524 }
1525
1526 #[test]
1527 fn reduce_empty_returns_none() {
1528 assert!(reduce_session(&[], "", "sid").is_none());
1529 }
1530
1531 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1534 format!(
1535 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}}}}}"#
1536 )
1537 }
1538
1539 #[test]
1540 fn parse_extracts_tool_use_names() {
1541 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"}}]"#;
1542 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1543 let (_tmp, path) = write_jsonl(&[&line]);
1544 let entries = parse_jsonl_entries(&path).unwrap().entries;
1545 assert_eq!(entries.len(), 1);
1546 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1547 }
1548
1549 #[test]
1550 fn parse_captures_text_char_count() {
1551 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1552 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1553 let (_tmp, path) = write_jsonl(&[&line]);
1554 let entries = parse_jsonl_entries(&path).unwrap().entries;
1555 assert_eq!(entries[0].text_chars, 12); }
1557
1558 #[test]
1559 fn parse_handles_missing_content_gracefully() {
1560 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1563 let (_tmp, path) = write_jsonl(&[&line]);
1564 let entries = parse_jsonl_entries(&path).unwrap().entries;
1565 assert_eq!(entries.len(), 1);
1566 assert!(entries[0].tool_uses.is_empty());
1567 assert_eq!(entries[0].text_chars, 0);
1568 assert_eq!(entries[0].activity, Activity::General);
1569 }
1570
1571 #[test]
1572 fn parse_classifies_edit_plus_debug_as_debugging() {
1573 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();
1575 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1576 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1577 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1578 let entries = parse_jsonl_entries(&path).unwrap().entries;
1579 assert_eq!(entries.len(), 1);
1580 assert_eq!(entries[0].activity, Activity::Debugging);
1581 }
1582
1583 #[test]
1584 fn parse_classifies_bash_pytest_as_testing() {
1585 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1586 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1587 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1588 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1589 let entries = parse_jsonl_entries(&path).unwrap().entries;
1590 assert_eq!(entries[0].activity, Activity::Testing);
1591 }
1592
1593 #[test]
1594 fn parse_classifies_bash_git_as_gitops() {
1595 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1596 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1597 let (_tmp, path) = write_jsonl(&[&line]);
1598 let entries = parse_jsonl_entries(&path).unwrap().entries;
1599 assert_eq!(entries[0].activity, Activity::GitOps);
1600 }
1601
1602 #[test]
1603 fn parse_tool_result_row_does_not_overwrite_user_text() {
1604 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1609 let asst1 = assistant_with_content(
1610 "msg_1",
1611 "2026-04-14T08:27:35.000Z",
1612 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1613 );
1614 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();
1615 let asst2 = assistant_with_content(
1616 "msg_2",
1617 "2026-04-14T08:27:40.000Z",
1618 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1619 );
1620 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1621 let entries = parse_jsonl_entries(&path).unwrap().entries;
1622 assert_eq!(entries.len(), 2);
1623 assert_eq!(entries[0].activity, Activity::Exploration);
1625 assert_eq!(entries[1].activity, Activity::Refactor);
1628 }
1629
1630 #[test]
1631 fn parse_classifies_agent_turn_as_delegation() {
1632 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1633 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1634 let (_tmp, path) = write_jsonl(&[&line]);
1635 let entries = parse_jsonl_entries(&path).unwrap().entries;
1636 assert_eq!(entries[0].activity, Activity::Delegation);
1637 }
1638
1639 #[test]
1640 fn parse_pure_text_turn_is_conversation() {
1641 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1642 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1643 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1644 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1645 let entries = parse_jsonl_entries(&path).unwrap().entries;
1646 assert_eq!(entries[0].activity, Activity::Conversation);
1647 assert!(entries[0].text_chars > 0);
1648 }
1649
1650 #[test]
1651 fn parse_user_array_content_extracts_text_blocks() {
1652 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();
1655 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1656 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1657 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1658 let entries = parse_jsonl_entries(&path).unwrap().entries;
1659 assert_eq!(entries[0].activity, Activity::Debugging);
1660 }
1661
1662 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1668 format!(
1669 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}}}}}"#
1670 )
1671 }
1672
1673 #[test]
1674 fn parse_merges_tool_uses_across_split_content_rows() {
1675 let thinking = assistant_split_row(
1681 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1682 "2026-05-13T09:09:03.500Z",
1683 r#"[{"type":"thinking","thinking":"plan"}]"#,
1684 );
1685 let text = assistant_split_row(
1686 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1687 "2026-05-13T09:09:03.550Z",
1688 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1689 );
1690 let tu1 = assistant_split_row(
1691 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1692 "2026-05-13T09:09:03.600Z",
1693 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1694 );
1695 let tu2 = assistant_split_row(
1696 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1697 "2026-05-13T09:09:03.700Z",
1698 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1699 );
1700 let tu3 = assistant_split_row(
1701 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1702 "2026-05-13T09:09:03.800Z",
1703 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1704 );
1705 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1706
1707 let entries = parse_jsonl_entries(&path).unwrap().entries;
1708 assert_eq!(entries.len(), 1, "still one entry per message.id");
1709
1710 let e = &entries[0];
1711 assert_eq!(
1712 e.tool_uses,
1713 vec![
1714 "mcp__example__list_configs".to_string(),
1715 "mcp__example__get_report".to_string(),
1716 "Edit".to_string(),
1717 ],
1718 "all tool_use names merged in row order"
1719 );
1720 assert!(e.text_chars > 0, "text from the text row is preserved");
1721
1722 assert_eq!(e.input_tokens, 6);
1726 assert_eq!(e.output_tokens, 746);
1727 assert_eq!(e.cache_read_tokens, 21859);
1728 assert_eq!(e.cache_write_tokens, 33027);
1729 }
1730
1731 #[test]
1732 fn parse_split_row_classifies_against_merged_content() {
1733 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();
1738 let thinking = assistant_split_row(
1739 "msg_split",
1740 "2026-05-13T09:09:01.000Z",
1741 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1742 );
1743 let edit = assistant_split_row(
1744 "msg_split",
1745 "2026-05-13T09:09:02.000Z",
1746 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1747 );
1748 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1749
1750 let entries = parse_jsonl_entries(&path).unwrap().entries;
1751 assert_eq!(entries.len(), 1);
1752 assert_eq!(entries[0].activity, Activity::Debugging);
1753 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1754 }
1755
1756 #[test]
1757 fn parse_split_row_bash_commands_feed_classifier() {
1758 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1761 let text = assistant_split_row(
1762 "msg_bash",
1763 "2026-05-13T09:09:01.000Z",
1764 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1765 );
1766 let bash = assistant_split_row(
1767 "msg_bash",
1768 "2026-05-13T09:09:02.000Z",
1769 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1770 );
1771 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1772
1773 let entries = parse_jsonl_entries(&path).unwrap().entries;
1774 assert_eq!(entries[0].activity, Activity::Testing);
1775 }
1776
1777 #[test]
1780 fn parse_extracts_hook_success_events() {
1781 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}}"#;
1782 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}}"#;
1783 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"}}"#;
1785 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1786 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1787
1788 let parsed = parse_jsonl_entries(&path).unwrap();
1789 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1790 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1791
1792 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1793 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1794 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1795 assert_eq!(parsed.hooks[0].exit_code, 0);
1796 assert_eq!(parsed.hooks[0].duration_ms, 90);
1797
1798 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1799
1800 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1801 assert_eq!(parsed.hooks[1].exit_code, 1);
1802 assert_eq!(parsed.hooks[1].duration_ms, 250);
1803 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1804 }
1805
1806 #[test]
1807 fn parse_ignores_attachment_without_hook_data() {
1808 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1810 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1811 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1812
1813 let parsed = parse_jsonl_entries(&path).unwrap();
1814 assert_eq!(parsed.entries.len(), 1);
1815 assert!(parsed.hooks.is_empty());
1816 }
1817
1818 #[test]
1819 fn snake_to_pascal_converts_correctly() {
1820 assert_eq!(snake_to_pascal("stop"), "Stop");
1821 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1822 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1823 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1824 }
1825
1826 #[test]
1827 fn parse_extracts_system_hook_summary_v2() {
1828 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"}"#;
1830 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1831 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1832
1833 let parsed = parse_jsonl_entries(&path).unwrap();
1834 assert_eq!(parsed.entries.len(), 1);
1835 assert_eq!(parsed.hooks.len(), 1);
1836
1837 let h = &parsed.hooks[0];
1838 assert_eq!(h.hook_event, "Stop");
1839 assert_eq!(h.command, "Running build checks...");
1840 assert_eq!(h.duration_ms, 5227);
1841 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1842 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1843 }
1844
1845 #[test]
1846 fn parse_system_hook_summary_with_errors() {
1847 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"}"#;
1848 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1849 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1850
1851 let parsed = parse_jsonl_entries(&path).unwrap();
1852 assert_eq!(parsed.hooks.len(), 1);
1853 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1854 }
1855
1856 #[test]
1857 fn parse_system_hook_summary_multiple_hooks() {
1858 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"}"#;
1859 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1860 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1861
1862 let parsed = parse_jsonl_entries(&path).unwrap();
1863 assert_eq!(parsed.hooks.len(), 2);
1864 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1865 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1866 assert_eq!(parsed.hooks[1].command, "cargo check");
1867 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1868 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1869 }
1870
1871 #[test]
1872 fn hook_name_label_sanitizes_long_multiline_commands() {
1873 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.";
1877 let escaped = serde_json::to_string(long_cmd).unwrap();
1878 let summary = format!(
1879 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"}}"#
1880 );
1881 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1882 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1883
1884 let parsed = parse_jsonl_entries(&path).unwrap();
1885 assert_eq!(parsed.hooks.len(), 1);
1886 let h = &parsed.hooks[0];
1887
1888 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1889 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1890 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1892 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1893 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1894 }
1895
1896 #[test]
1897 fn hook_name_label_preserves_short_commands() {
1898 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"}"#;
1899 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1900 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1901
1902 let parsed = parse_jsonl_entries(&path).unwrap();
1903 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1904 }
1905
1906 #[test]
1907 fn parse_handles_both_formats_in_same_file() {
1908 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}}"#;
1910 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"}"#;
1912 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1913 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1914
1915 let parsed = parse_jsonl_entries(&path).unwrap();
1916 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1917 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1918 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1919 }
1920
1921 #[allow(clippy::too_many_arguments)]
1924 fn entry_for_cost(
1925 model: &str,
1926 input: u64,
1927 output: u64,
1928 cr: u64,
1929 cw_total: u64,
1930 cw_1h: u64,
1931 web: u64,
1932 speed: Option<&str>,
1933 ) -> UsageEntry {
1934 UsageEntry {
1935 session_id: "s".into(),
1936 timestamp_ms: 0,
1937 model: model.into(),
1938 input_tokens: input,
1939 output_tokens: output,
1940 cache_read_tokens: cr,
1941 cache_write_tokens: cw_total,
1942 cache_write_1h_tokens: cw_1h,
1943 web_search_requests: web,
1944 speed: speed.map(|s| s.to_string()),
1945 tool_uses: Vec::new(),
1946 text_chars: 0,
1947 activity: Activity::General,
1948 entrypoint: None,
1949 }
1950 }
1951
1952 #[test]
1953 fn fable_pricing_all_token_classes() {
1954 let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1956 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1957 let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1958 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1959 let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1960 assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1961 let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1962 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1963 let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1964 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1965 }
1966
1967 #[test]
1968 fn fable_has_no_fast_premium() {
1969 let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1972 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1973 }
1974
1975 #[test]
1976 fn fable_context_window_is_natively_1m() {
1977 assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1979 assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1980 assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1981 }
1982
1983 #[test]
1984 fn cost_splits_5m_and_1h_cache_writes() {
1985 let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1987 assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1988 let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1989 assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1990 let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1992 assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1993 }
1994
1995 #[test]
1996 fn cost_adds_flat_web_search_fee() {
1997 let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1999 assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
2000 }
2001
2002 #[test]
2003 fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
2004 let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
2006 assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
2007 let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2008 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
2009 let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2011 assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
2012 let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
2014 assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
2015 }
2016
2017 #[test]
2018 fn fast_multiplier_only_for_known_opus() {
2019 assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
2020 assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
2021 assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
2022 assert_eq!(fast_multiplier_for_model("claude-opus-5"), 2.0);
2023 assert_eq!(fast_multiplier_for_model("claude-opus-5[1m]"), 2.0);
2024 assert_eq!(fast_multiplier_for_model("claude-opus-5-5"), 2.0);
2025 assert_eq!(fast_multiplier_for_model("claude-opus-5-5[1m]"), 2.0);
2026 assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
2027 assert_eq!(fast_multiplier_for_model("claude-fable-5-1"), 1.0);
2028 assert_eq!(fast_multiplier_for_model("claude-opus-6"), 1.0);
2030 }
2031
2032 #[test]
2033 fn fable_5_1_reads_cache_at_a_quarter_of_fable_5() {
2034 let read51 = entry_for_cost("claude-fable-5-1", 0, 0, 1_000_000, 0, 0, 0, None);
2037 assert!((cost_for_entry(&read51) - 0.25).abs() < 1e-6);
2038 let read5 = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
2039 assert!((cost_for_entry(&read5) - 1.0).abs() < 1e-6);
2040
2041 let input = entry_for_cost("claude-fable-5-1", 1_000_000, 0, 0, 0, 0, 0, None);
2042 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
2043 let output = entry_for_cost("claude-fable-5-1", 0, 1_000_000, 0, 0, 0, 0, None);
2044 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
2045 let w5m = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 0, 0, None);
2046 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
2047 let w1h = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2048 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
2049 }
2050
2051 #[test]
2052 fn opus_5_5_is_cheaper_than_opus_5() {
2053 let in55 = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, None);
2056 assert!((cost_for_entry(&in55) - 4.0).abs() < 1e-6);
2057 let out55 = entry_for_cost("claude-opus-5-5", 0, 1_000_000, 0, 0, 0, 0, None);
2058 assert!((cost_for_entry(&out55) - 20.0).abs() < 1e-6);
2059 let read55 = entry_for_cost("claude-opus-5-5", 0, 0, 1_000_000, 0, 0, 0, None);
2060 assert!((cost_for_entry(&read55) - 0.2).abs() < 1e-6);
2061 let w5m = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 0, 0, None);
2062 assert!((cost_for_entry(&w5m) - 5.0).abs() < 1e-6);
2063 let w1h = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2064 assert!((cost_for_entry(&w1h) - 8.0).abs() < 1e-6);
2065
2066 let in55_1m = entry_for_cost("claude-opus-5-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2068 assert!((cost_for_entry(&in55_1m) - 4.0).abs() < 1e-6);
2069 let fast = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2070 assert!((cost_for_entry(&fast) - 8.0).abs() < 1e-6);
2071
2072 let in5 = entry_for_cost("claude-opus-5", 1_000_000, 0, 0, 0, 0, 0, None);
2074 assert!((cost_for_entry(&in5) - 5.0).abs() < 1e-6);
2075 assert_eq!(pricing_for_model("opus").input, 5.0);
2076 assert_eq!(pricing_for_model("opus").cache_read, 0.5);
2077 }
2078
2079 #[test]
2080 fn sonnet_5_is_cheaper_than_sonnet_4_6() {
2081 let in5 = entry_for_cost("claude-sonnet-5", 1_000_000, 0, 0, 0, 0, 0, None);
2084 assert!((cost_for_entry(&in5) - 2.0).abs() < 1e-6);
2085 let out5 = entry_for_cost("claude-sonnet-5", 0, 1_000_000, 0, 0, 0, 0, None);
2086 assert!((cost_for_entry(&out5) - 10.0).abs() < 1e-6);
2087 let read5 = entry_for_cost("claude-sonnet-5", 0, 0, 1_000_000, 0, 0, 0, None);
2088 assert!((cost_for_entry(&read5) - 0.2).abs() < 1e-6);
2089 let w5m = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 0, 0, None);
2090 assert!((cost_for_entry(&w5m) - 2.5).abs() < 1e-6);
2091 let w1h = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2092 assert!((cost_for_entry(&w1h) - 4.0).abs() < 1e-6);
2093
2094 let in46 = entry_for_cost("claude-sonnet-4-6", 1_000_000, 0, 0, 0, 0, 0, None);
2095 assert!((cost_for_entry(&in46) - 3.0).abs() < 1e-6);
2096
2097 let in5_1m = entry_for_cost("claude-sonnet-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2099 assert!((cost_for_entry(&in5_1m) - 2.0).abs() < 1e-6);
2100 }
2101
2102 #[test]
2103 fn haiku_5_5_is_a_tenth_of_haiku_4_5() {
2104 for id in ["claude-haiku-5-5", "claude-haiku-5-5-20261007"] {
2106 let p = pricing_for_model(id);
2107 assert_eq!(p.input, 0.1, "{id}");
2108 assert_eq!(p.output, 0.5, "{id}");
2109 assert_eq!(p.cache_read, 0.01, "{id}");
2110 assert_eq!(p.cache_write, 0.125, "{id}");
2111 assert_eq!(p.cache_write_1h, 0.2, "{id}");
2112 }
2113 for id in ["claude-haiku-4-5", "claude-haiku-4-5-20251001", "haiku"] {
2115 let p = pricing_for_model(id);
2116 assert_eq!(p.input, 1.0, "{id}");
2117 assert_eq!(p.output, 5.0, "{id}");
2118 assert_eq!(p.cache_read, 0.1, "{id}");
2119 }
2120 }
2121
2122 #[test]
2123 fn haiku_5_5_long_prompts_bill_the_higher_tier() {
2124 let model = "claude-haiku-5-5";
2125 let at = entry_for_cost(model, 50_000, 0, 30_000, 20_000, 0, 0, None);
2127 let want_at = (50_000.0 * 0.1 + 30_000.0 * 0.01 + 20_000.0 * 0.125) / 1e6;
2128 assert!((cost_for_entry(&at) - want_at).abs() < 1e-9);
2129 let over = entry_for_cost(model, 50_000, 0, 30_000, 20_001, 0, 0, None);
2130 let want_over = (50_000.0 * 0.5 + 30_000.0 * 0.05 + 20_001.0 * 0.625) / 1e6;
2131 assert!((cost_for_entry(&over) - want_over).abs() < 1e-9);
2132
2133 let out = entry_for_cost(model, 100_001, 1_000_000, 0, 0, 0, 0, None);
2135 assert!((cost_for_entry(&out) - (100_001.0 * 0.5 + 2_500_000.0) / 1e6).abs() < 1e-9);
2136 let w1h = entry_for_cost(model, 0, 0, 0, 200_000, 200_000, 0, None);
2137 assert!((cost_for_entry(&w1h) - 0.2).abs() < 1e-9);
2138
2139 let h45 = entry_for_cost("claude-haiku-4-5", 200_000, 0, 0, 0, 0, 0, None);
2141 assert!((cost_for_entry(&h45) - 0.2).abs() < 1e-9);
2142 }
2143
2144 #[test]
2145 fn haiku_5_5_context_window_is_natively_1m() {
2146 assert_eq!(context_window_for_model("claude-haiku-5-5"), 1_000_000);
2147 assert_eq!(context_window_for_model("claude-haiku-4-5"), 200_000);
2148 assert_eq!(context_window_for_model("claude-haiku-4-5-20251001"), 200_000);
2149 }
2150
2151 #[test]
2152 fn unknown_models_keep_the_conservative_sonnet_rate() {
2153 for id in ["", "<unknown>", "sonnet", "claude-3-5-sonnet-20241022"] {
2156 let p = pricing_for_model(id);
2157 assert_eq!(p.input, 3.0, "{id} should price at the Sonnet default");
2158 }
2159 assert_eq!(pricing_for_model("fable").cache_read, 1.0);
2162 }
2163
2164 #[test]
2165 fn family_version_reads_minor_and_ignores_date_snapshots() {
2166 assert_eq!(family_version("claude-fable-5-1", "fable"), Some((5, 1)));
2167 assert_eq!(family_version("claude-fable-5", "fable"), Some((5, 0)));
2168 assert_eq!(family_version("claude-sonnet-5", "sonnet"), Some((5, 0)));
2169 assert_eq!(family_version("claude-sonnet-4-6", "sonnet"), Some((4, 6)));
2170 assert_eq!(family_version("claude-opus-5-5", "opus"), Some((5, 5)));
2171 assert_eq!(family_version("claude-opus-5-5[1m]", "opus"), Some((5, 5)));
2172 assert_eq!(
2173 family_version("claude-haiku-4-5-20251001", "haiku"),
2174 Some((4, 5))
2175 );
2176 assert_eq!(
2178 family_version("claude-sonnet-5-20260701", "sonnet"),
2179 Some((5, 0))
2180 );
2181 assert_eq!(family_version("fable", "fable"), None);
2182 assert_eq!(family_version("claude-opus-4-8", "sonnet"), None);
2183 assert_eq!(family_version("claude-3-5-sonnet-20241022", "sonnet"), None);
2186 }
2187
2188 #[test]
2189 fn parse_extracts_cache_split_speed_and_web_search() {
2190 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"}}}"#;
2191 let (_tmp, path) = write_jsonl(&[line]);
2192 let entries = parse_jsonl_entries(&path).unwrap().entries;
2193 assert_eq!(entries.len(), 1);
2194 let e = &entries[0];
2195 assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
2196 assert_eq!(e.cache_write_1h_tokens, 40002);
2197 assert_eq!(e.web_search_requests, 2);
2198 assert_eq!(e.speed.as_deref(), Some("fast"));
2199 }
2200
2201 #[test]
2202 fn parse_cache_write_takes_max_of_flat_and_split() {
2203 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}}}}"#;
2205 let (_tmp, path) = write_jsonl(&[line]);
2206 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2207 assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
2208 assert_eq!(e.cache_write_1h_tokens, 900);
2209 }
2210
2211 #[test]
2212 fn parse_missing_nested_cache_falls_back_to_flat() {
2213 let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
2215 let (_tmp, path) = write_jsonl(&[&line]);
2216 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2217 assert_eq!(e.cache_write_tokens, 30000);
2218 assert_eq!(e.cache_write_1h_tokens, 0);
2219 assert_eq!(e.web_search_requests, 0);
2220 assert_eq!(e.speed, None);
2221 }
2222
2223 #[test]
2224 fn reduce_session_prices_1h_cache_premium() {
2225 let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2227 let u = reduce_session(&[e], "", "sid").unwrap();
2228 assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
2229 }
2230}