agent-cost-tracker 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_cost/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Token, cache and context observability for AI coding agents."""
2
+
3
+ from .models import SessionStats
4
+ from .pricing import estimate_cost
5
+
6
+ __all__ = ["SessionStats", "estimate_cost"]
7
+ __version__ = "0.1.0"
agent_cost/analyze.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from agent_cost.models import SessionStats
4
+
5
+
6
+ def analyze(stats: SessionStats) -> dict:
7
+ """Derive actionable signals from session statistics."""
8
+ signals: dict = {"recommendations": [], "context_growth": None, "largest_sources": []}
9
+
10
+ samples = [s["estimated_prompt_tokens"] for s in stats.context_samples]
11
+ if len(samples) >= 4:
12
+ half = len(samples) // 2
13
+ first = sum(samples[:half]) / half
14
+ second = sum(samples[half:]) / (len(samples) - half)
15
+ ratio = second / first if first else 0.0
16
+ signals["context_growth"] = {
17
+ "first_half_avg": round(first),
18
+ "second_half_avg": round(second),
19
+ "growth_ratio": round(ratio, 2),
20
+ }
21
+ if ratio >= 2.5:
22
+ signals["recommendations"].append(
23
+ "Prompt size is growing steeply; start a fresh session instead of continuing."
24
+ )
25
+ elif ratio >= 1.6:
26
+ signals["recommendations"].append(
27
+ "Context is growing steadily; budget a /compact or a new session soon."
28
+ )
29
+
30
+ if stats.compaction_events:
31
+ signals["recommendations"].append(
32
+ f"Session was already compacted {stats.compaction_events}x; further work belongs in a new session."
33
+ )
34
+
35
+ if stats.turns >= 25 and not stats.context_samples:
36
+ signals["recommendations"].append("High turn count; review whether a new session would be cheaper.")
37
+
38
+ has_cache_data = bool(stats.cache_read_tokens or stats.cache_write_tokens)
39
+ if has_cache_data and stats.cache_hit_rate < 0.4 and stats.prompt_tokens:
40
+ signals["recommendations"].append(
41
+ "Low cache hit rate; same-prefix reuse is low, which usually inflates input cost."
42
+ )
43
+
44
+ total_chars = sum(stats.source_chars.values())
45
+ if total_chars:
46
+ ranked = sorted(stats.source_chars.items(), key=lambda kv: kv[1], reverse=True)[:4]
47
+ signals["largest_sources"] = [
48
+ {"source": k, "percent": round(v * 100 / total_chars, 1)} for k, v in ranked
49
+ ]
50
+ if any(k == "tool_output" for k, _ in ranked[:2]):
51
+ signals["recommendations"].append(
52
+ "Tool output dominates context; consider truncating or filtering large command output."
53
+ )
54
+
55
+ return signals
agent_cost/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import dataclasses
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from agent_cost.analyze import analyze
10
+ from agent_cost.compare import compare_sessions
11
+ from agent_cost.models import SessionStats
12
+ from agent_cost.parsers.detect import load_path
13
+ from agent_cost.pricing import estimate_session_cost
14
+ from agent_cost.report import format_analyze, format_compare, format_inspect, format_stats_table
15
+
16
+
17
+ def _pricing_override() -> dict | None:
18
+ env = __import__("os").environ.get("AGENT_COST_PRICING")
19
+ return json.loads(env) if env else None
20
+
21
+
22
+ def _load_all(paths: list[str]) -> list[SessionStats]:
23
+ stats: list[SessionStats] = []
24
+ for path in paths:
25
+ loaded = load_path(path)
26
+ stats.extend(loaded)
27
+ return stats
28
+
29
+
30
+ def _fill_cost(
31
+ stats: SessionStats,
32
+ custom_pricing: dict | None = None,
33
+ subscription: bool = False,
34
+ ) -> float | None:
35
+ if stats.cost_status in ("actual", "estimated", "included") and stats.estimated_cost_usd > 0:
36
+ cost = stats.estimated_cost_usd
37
+ else:
38
+ cost, status = estimate_session_cost(stats, custom_pricing)
39
+ if cost is None:
40
+ return None
41
+ stats.estimated_cost_usd = cost
42
+ stats.cost_status = status
43
+
44
+ if subscription and stats.cost_status == "estimated":
45
+ # The tokens were real, the dollars were not spent: a subscription
46
+ # already covers them. Keep the figure, change what it claims to be.
47
+ stats.cost_status = "included"
48
+ return cost
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ parser = argparse.ArgumentParser(
53
+ prog="agent-cost",
54
+ description="Token, cache and context observability for AI coding agents.",
55
+ )
56
+ parser.add_argument(
57
+ "--pricing",
58
+ help="JSON string with custom pricing override.",
59
+ default=None,
60
+ )
61
+ parser.add_argument(
62
+ "--subscription",
63
+ action="store_true",
64
+ default=False,
65
+ help="Bill through a Claude Pro/Max (or similar) subscription: report costs "
66
+ "as API-equivalent value rather than money spent.",
67
+ )
68
+ sub = parser.add_subparsers(dest="command", required=True)
69
+
70
+ p_inspect = sub.add_parser("inspect", help="Show a single session's usage and cost.")
71
+ p_inspect.add_argument("paths", nargs="+", help="Session files or directories.")
72
+
73
+ p_analyze = sub.add_parser("analyze", help="Context growth and action recommendations.")
74
+ p_analyze.add_argument("paths", nargs="+", help="Session files or directories.")
75
+
76
+ p_stats = sub.add_parser("stats", help="Aggregate totals across sessions.")
77
+ p_stats.add_argument("paths", nargs="+", help="Session files or directories.")
78
+
79
+ p_compare = sub.add_parser("compare", help="Compare usage, cache efficiency, and cost across multiple agents.")
80
+ p_compare.add_argument("paths", nargs="+", help="Session files or directories to compare.")
81
+ p_compare.add_argument("--by-agent", action="store_true", default=False, help="Group comparison strictly by agent.")
82
+ p_compare.add_argument("--json", action="store_true", default=False, help="Output comparison result as JSON.")
83
+
84
+ args = parser.parse_args(argv)
85
+
86
+ custom_pricing = _pricing_override()
87
+ if args.pricing:
88
+ try:
89
+ custom_pricing = json.loads(args.pricing)
90
+ except json.JSONDecodeError:
91
+ print("Error: Invalid JSON for --pricing", file=sys.stderr)
92
+ return 2
93
+
94
+ stats = _load_all(args.paths)
95
+ if not stats:
96
+ print("No sessions found.", file=sys.stderr)
97
+ return 1
98
+
99
+ if args.command == "inspect":
100
+ for s in stats:
101
+ _fill_cost(s, custom_pricing, args.subscription)
102
+ print(format_inspect(s))
103
+ print()
104
+ elif args.command == "analyze":
105
+ for s in stats:
106
+ _fill_cost(s, custom_pricing, args.subscription)
107
+ signals = analyze(s)
108
+ print(format_analyze(s, signals))
109
+ print()
110
+ elif args.command == "stats":
111
+ for s in stats:
112
+ _fill_cost(s, custom_pricing, args.subscription)
113
+ print(format_stats_table(stats))
114
+ elif args.command == "compare":
115
+ result = compare_sessions(stats, custom_pricing, args.subscription)
116
+ if args.json:
117
+ out = {
118
+ "total_sessions": result.total_sessions,
119
+ "total_tokens": result.total_tokens,
120
+ "total_cost_usd": result.total_cost_usd,
121
+ "total_cache_savings_usd": result.total_cache_savings_usd,
122
+ # Dollar totals cover only the priced sessions; this says how many
123
+ # were left out so a consumer never reads them as complete.
124
+ "unpriced_sessions": result.unpriced_sessions,
125
+ "insights": result.insights,
126
+ "agents": {
127
+ k: dataclasses.asdict(v) for k, v in result.agent_summaries.items()
128
+ },
129
+ }
130
+ # Convert sets to lists for json serialization
131
+ for a in out["agents"].values():
132
+ if isinstance(a.get("models"), set):
133
+ a["models"] = list(a["models"])
134
+ print(json.dumps(out, indent=2, ensure_ascii=False))
135
+ else:
136
+ print(format_compare(result, by_agent=args.by_agent))
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ raise SystemExit(main())
agent_cost/compare.py ADDED
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from agent_cost.models import SessionStats
5
+ from agent_cost.pricing import estimate_cost, estimate_session_cost
6
+
7
+
8
+ @dataclass
9
+ class AgentSummary:
10
+ agent: str
11
+ session_count: int = 0
12
+ models: set[str] = field(default_factory=set)
13
+ total_tokens: int = 0
14
+ input_tokens: int = 0
15
+ cache_read_tokens: int = 0
16
+ cache_write_tokens: int = 0
17
+ output_tokens: int = 0
18
+ estimated_cost_usd: float = 0.0
19
+ turns: int = 0
20
+ tool_calls: int = 0
21
+ compaction_events: int = 0
22
+ unpriced_sessions: int = 0
23
+
24
+ @property
25
+ def prompt_tokens(self) -> int:
26
+ return self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
27
+
28
+ @property
29
+ def cache_hit_rate(self) -> float:
30
+ prompt = self.prompt_tokens
31
+ return self.cache_read_tokens / prompt if prompt else 0.0
32
+
33
+ @property
34
+ def avg_cost_per_session(self) -> float:
35
+ return self.estimated_cost_usd / self.session_count if self.session_count else 0.0
36
+
37
+ @property
38
+ def avg_tokens_per_turn(self) -> float:
39
+ return self.total_tokens / self.turns if self.turns else 0.0
40
+
41
+
42
+ @dataclass
43
+ class CompareResult:
44
+ sessions: list[SessionStats]
45
+ agent_summaries: dict[str, AgentSummary]
46
+ total_sessions: int
47
+ total_tokens: int
48
+ total_cost_usd: float
49
+ total_cache_savings_usd: float
50
+ unpriced_sessions: int = 0
51
+ insights: list[str] = field(default_factory=list)
52
+
53
+
54
+ def _dominant_mode(stats: SessionStats) -> tuple[str, str]:
55
+ """The billing mode most of this session's prompt tokens were billed on.
56
+
57
+ Used only for the cache-savings counterfactual, which is a single
58
+ "what if nothing had been cached" figure and so needs one mode, not a
59
+ per-bucket split.
60
+ """
61
+ buckets = getattr(stats, "billing_buckets", None)
62
+ if not buckets:
63
+ return "standard", ""
64
+ key = max(buckets, key=lambda k: buckets[k].get("cache_read", 0) + buckets[k].get("input", 0))
65
+ speed, _, geo = key.partition("|")
66
+ return speed, geo
67
+
68
+
69
+ def _dominant_speed(stats: SessionStats) -> str:
70
+ return _dominant_mode(stats)[0]
71
+
72
+
73
+ def _dominant_geo(stats: SessionStats) -> str:
74
+ return _dominant_mode(stats)[1]
75
+
76
+
77
+ def compare_sessions(
78
+ sessions: list[SessionStats],
79
+ custom_pricing: dict | None = None,
80
+ subscription: bool = False,
81
+ ) -> CompareResult:
82
+ """Perform side-by-side comparative analysis of multiple agent sessions."""
83
+ summaries: dict[str, AgentSummary] = {}
84
+ total_tokens = 0
85
+ total_cost = 0.0
86
+ total_savings = 0.0
87
+
88
+ for s in sessions:
89
+ # Ensure cost is filled
90
+ if s.cost_status == "unknown" or s.estimated_cost_usd == 0.0:
91
+ c, st = estimate_session_cost(s, custom_pricing)
92
+ if c is not None:
93
+ s.estimated_cost_usd = c
94
+ s.cost_status = st
95
+ if subscription and s.cost_status == "estimated":
96
+ s.cost_status = "included"
97
+
98
+ agent = s.agent or "unknown"
99
+ if agent not in summaries:
100
+ summaries[agent] = AgentSummary(agent=agent)
101
+
102
+ summary = summaries[agent]
103
+ summary.session_count += 1
104
+ if s.model:
105
+ summary.models.add(s.model)
106
+ summary.total_tokens += s.total_tokens
107
+ summary.input_tokens += s.input_tokens
108
+ summary.cache_read_tokens += s.cache_read_tokens
109
+ summary.cache_write_tokens += s.cache_write_tokens
110
+ summary.output_tokens += s.output_tokens
111
+ summary.estimated_cost_usd += s.estimated_cost_usd
112
+ summary.turns += s.turns
113
+ summary.tool_calls += s.tool_calls
114
+ summary.compaction_events += s.compaction_events
115
+ if s.cost_status == "unknown":
116
+ summary.unpriced_sessions += 1
117
+
118
+ total_tokens += s.total_tokens
119
+ total_cost += s.estimated_cost_usd
120
+
121
+ # Calculate approximate cache savings (cost if cache_read was full price input)
122
+ if s.cache_read_tokens > 0:
123
+ # Assume base input price difference
124
+ full_cost, _ = estimate_cost(
125
+ s.input_tokens + s.cache_read_tokens,
126
+ s.output_tokens,
127
+ 0,
128
+ s.cache_write_tokens,
129
+ s.model,
130
+ custom_pricing,
131
+ speed=_dominant_speed(s),
132
+ inference_geo=_dominant_geo(s),
133
+ )
134
+ if full_cost is not None and full_cost > s.estimated_cost_usd:
135
+ total_savings += (full_cost - s.estimated_cost_usd)
136
+
137
+ # Generate insights
138
+ insights = []
139
+ if summaries:
140
+ # Highest cache efficiency
141
+ best_cache = max(summaries.values(), key=lambda a: a.cache_hit_rate)
142
+ if best_cache.cache_hit_rate > 0.1:
143
+ insights.append(
144
+ f"Highest cache efficiency: {best_cache.agent} ({best_cache.cache_hit_rate*100:.1f}% hit rate)."
145
+ )
146
+
147
+ # Most tool active
148
+ most_tools = max(summaries.values(), key=lambda a: a.tool_calls)
149
+ if most_tools.tool_calls > 0:
150
+ insights.append(
151
+ f"Most tool intensive: {most_tools.agent} ({most_tools.tool_calls} total tool calls)."
152
+ )
153
+
154
+ # Cost saving impact
155
+ if total_savings > 0:
156
+ insights.append(f"Prompt caching saved approx. ${total_savings:.2f} across analyzed sessions.")
157
+
158
+ unpriced = sum(1 for s in sessions if s.cost_status == "unknown")
159
+ if unpriced:
160
+ insights.append(
161
+ f"{unpriced} of {len(sessions)} sessions have no rate card and are excluded from "
162
+ f"every dollar figure above. Supply rates with --pricing to include them."
163
+ )
164
+
165
+ return CompareResult(
166
+ sessions=sessions,
167
+ agent_summaries=summaries,
168
+ total_sessions=len(sessions),
169
+ total_tokens=total_tokens,
170
+ total_cost_usd=total_cost,
171
+ total_cache_savings_usd=total_savings,
172
+ unpriced_sessions=unpriced,
173
+ insights=insights,
174
+ )
agent_cost/models.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class SessionStats:
8
+ """Aggregated usage statistics for one agent session."""
9
+
10
+ agent: str
11
+ session_key: str
12
+ model: str = ""
13
+ platform: str = ""
14
+ cli_version: str = ""
15
+ cwd: str = ""
16
+ created_at: str = ""
17
+ updated_at: str = ""
18
+ input_tokens: int = 0
19
+ output_tokens: int = 0
20
+ cache_read_tokens: int = 0
21
+ cache_write_tokens: int = 0
22
+ estimated_cost_usd: float = 0.0
23
+ cost_status: str = "unknown"
24
+ turns: int = 0
25
+ tool_calls: int = 0
26
+ compaction_events: int = 0
27
+ context_samples: list[dict] = field(default_factory=list)
28
+ source_chars: dict[str, int] = field(default_factory=dict)
29
+ # Tokens split by billing mode, keyed "<speed>|<inference_geo>". Fast mode and
30
+ # US-pinned inference are priced differently and can change mid-session, so
31
+ # the totals above are not enough to price a session correctly. Parsers that
32
+ # cannot observe these modes leave this empty and are priced off the totals.
33
+ billing_buckets: dict[str, dict[str, int]] = field(default_factory=dict)
34
+
35
+ def add_usage(
36
+ self,
37
+ input_tokens: int,
38
+ output_tokens: int,
39
+ cache_read: int,
40
+ cache_write: int,
41
+ speed: str = "standard",
42
+ inference_geo: str = "",
43
+ ) -> None:
44
+ """Add one turn's usage to both the flat totals and its billing bucket."""
45
+ self.input_tokens += input_tokens
46
+ self.output_tokens += output_tokens
47
+ self.cache_read_tokens += cache_read
48
+ self.cache_write_tokens += cache_write
49
+
50
+ bucket = self.billing_buckets.setdefault(
51
+ f"{speed or 'standard'}|{inference_geo or ''}",
52
+ {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0},
53
+ )
54
+ bucket["input"] += input_tokens
55
+ bucket["output"] += output_tokens
56
+ bucket["cache_read"] += cache_read
57
+ bucket["cache_write"] += cache_write
58
+
59
+ @property
60
+ def prompt_tokens(self) -> int:
61
+ return self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
62
+
63
+ @property
64
+ def total_tokens(self) -> int:
65
+ return self.prompt_tokens + self.output_tokens
66
+
67
+ @property
68
+ def cache_hit_rate(self) -> float:
69
+ prompt = self.prompt_tokens
70
+ return self.cache_read_tokens / prompt if prompt else 0.0
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from agent_cost.parsers.claude import parse_claude_session
4
+ from agent_cost.parsers.codex import parse_codex_rollout
5
+ from agent_cost.parsers.detect import load_path, parse_file
6
+ from agent_cost.parsers.hermes import parse_hermes_sessions
7
+ from agent_cost.parsers.opencode import parse_opencode_session
8
+
9
+ __all__ = [
10
+ "load_path",
11
+ "parse_file",
12
+ "parse_claude_session",
13
+ "parse_codex_rollout",
14
+ "parse_hermes_sessions",
15
+ "parse_opencode_session",
16
+ ]
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from agent_cost.models import SessionStats
7
+
8
+
9
+ def _chars(text: object) -> int:
10
+ return len(str(text or ""))
11
+
12
+
13
+ def parse_claude_session(path: str | Path) -> SessionStats:
14
+ """Parse a Claude Code session log (JSON or JSONL) into SessionStats.
15
+
16
+ Claude Code logs interactions and Anthropic API usage objects:
17
+ - input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens
18
+ - tool_use content blocks (Bash, FileEdit, GlobTool, etc.)
19
+ """
20
+ p = Path(path)
21
+ stats = SessionStats(agent="claude-code", session_key=p.stem)
22
+
23
+ if p.suffix == ".json":
24
+ try:
25
+ data = json.loads(p.read_text(encoding="utf-8"))
26
+ if isinstance(data, dict):
27
+ return _parse_claude_dict(data, stats)
28
+ elif isinstance(data, list):
29
+ return _parse_claude_records(data, stats)
30
+ except json.JSONDecodeError:
31
+ pass
32
+
33
+ # Process JSONL format (standard Claude Code transcript)
34
+ turns = 0
35
+ cumulative_chars = 0
36
+ with p.open(encoding="utf-8") as fh:
37
+ for line in fh:
38
+ line = line.strip()
39
+ if not line:
40
+ continue
41
+ try:
42
+ event = json.loads(line)
43
+ except json.JSONDecodeError:
44
+ continue
45
+
46
+ _process_claude_event(event, stats)
47
+
48
+ if stats.turns == 0 and turns > 0:
49
+ stats.turns = turns
50
+ return stats
51
+
52
+
53
+ def _process_claude_event(event: dict, stats: SessionStats) -> None:
54
+ # Session metadata if present
55
+ if "session_id" in event or "sessionId" in event:
56
+ stats.session_key = str(event.get("session_id") or event.get("sessionId") or stats.session_key)
57
+ if "cwd" in event:
58
+ stats.cwd = str(event.get("cwd") or stats.cwd)
59
+ if "platform" in event or "origin" in event:
60
+ stats.platform = str(event.get("platform") or event.get("origin") or stats.platform)
61
+ if "created_at" in event or "timestamp" in event:
62
+ ts = str(event.get("created_at") or event.get("timestamp") or "")
63
+ if not stats.created_at:
64
+ stats.created_at = ts
65
+ stats.updated_at = ts
66
+
67
+ # Check for model
68
+ model = event.get("model") or (event.get("message", {}) if isinstance(event.get("message"), dict) else {}).get("model")
69
+ if model and not stats.model:
70
+ stats.model = str(model)
71
+
72
+ # Check for compaction or context prune events
73
+ etype = event.get("type") or ""
74
+ if etype in ("compacted", "context_pruned", "summary"):
75
+ stats.compaction_events += 1
76
+
77
+ # Extract usage
78
+ usage = event.get("usage")
79
+ if not usage and isinstance(event.get("message"), dict):
80
+ usage = event.get("message", {}).get("usage")
81
+
82
+ if isinstance(usage, dict):
83
+ inp = int(usage.get("input_tokens") or 0)
84
+ out = int(usage.get("output_tokens") or 0)
85
+ cache_read = int(usage.get("cache_read_input_tokens") or usage.get("cache_read_tokens") or 0)
86
+ cache_write = int(usage.get("cache_creation_input_tokens") or usage.get("cache_write_tokens") or 0)
87
+
88
+ # `speed` and `inference_geo` decide which rate card this turn is billed
89
+ # on, and both can change between turns, so they are recorded per turn.
90
+ stats.add_usage(
91
+ inp,
92
+ out,
93
+ cache_read,
94
+ cache_write,
95
+ speed=str(usage.get("speed") or "standard"),
96
+ inference_geo=str(usage.get("inference_geo") or ""),
97
+ )
98
+
99
+ stats.turns += 1
100
+ prompt_tokens_this_turn = inp + cache_read + cache_write
101
+ stats.context_samples.append({
102
+ "turn": stats.turns,
103
+ "estimated_prompt_tokens": prompt_tokens_this_turn,
104
+ })
105
+
106
+ # Count tool uses & source chars
107
+ content = event.get("content")
108
+ if not content and isinstance(event.get("message"), dict):
109
+ content = event.get("message", {}).get("content")
110
+
111
+ if isinstance(content, list):
112
+ for block in content:
113
+ if isinstance(block, dict):
114
+ btype = block.get("type")
115
+ if btype == "tool_use":
116
+ stats.tool_calls += 1
117
+ tool_name = block.get("name") or "tool"
118
+ stats.source_chars["tool_calls"] = stats.source_chars.get("tool_calls", 0) + _chars(tool_name)
119
+ elif btype == "tool_result":
120
+ res_text = block.get("content") or block.get("text") or ""
121
+ stats.source_chars["tool_output"] = stats.source_chars.get("tool_output", 0) + _chars(res_text)
122
+ elif btype == "text":
123
+ text = block.get("text") or ""
124
+ stats.source_chars["assistant"] = stats.source_chars.get("assistant", 0) + _chars(text)
125
+ elif isinstance(content, str):
126
+ role = event.get("role") or event.get("type") or "user"
127
+ stats.source_chars[role] = stats.source_chars.get(role, 0) + _chars(content)
128
+
129
+
130
+ def _parse_claude_dict(data: dict, stats: SessionStats) -> SessionStats:
131
+ stats.session_key = str(data.get("session_id") or data.get("id") or stats.session_key)
132
+ stats.model = str(data.get("model") or stats.model)
133
+ stats.created_at = str(data.get("created_at") or "")
134
+ stats.updated_at = str(data.get("updated_at") or "")
135
+
136
+ messages = data.get("messages") or data.get("transcript") or []
137
+ if isinstance(messages, list):
138
+ for item in messages:
139
+ if isinstance(item, dict):
140
+ _process_claude_event(item, stats)
141
+ return stats
142
+
143
+
144
+ def _parse_claude_records(records: list[dict], stats: SessionStats) -> SessionStats:
145
+ for rec in records:
146
+ if isinstance(rec, dict):
147
+ _process_claude_event(rec, stats)
148
+ return stats