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 +7 -0
- agent_cost/analyze.py +55 -0
- agent_cost/cli.py +141 -0
- agent_cost/compare.py +174 -0
- agent_cost/models.py +70 -0
- agent_cost/parsers/__init__.py +16 -0
- agent_cost/parsers/claude.py +148 -0
- agent_cost/parsers/codex.py +82 -0
- agent_cost/parsers/detect.py +190 -0
- agent_cost/parsers/hermes.py +36 -0
- agent_cost/parsers/opencode.py +318 -0
- agent_cost/pricing.py +191 -0
- agent_cost/report.py +187 -0
- agent_cost_tracker-0.2.1.dist-info/METADATA +227 -0
- agent_cost_tracker-0.2.1.dist-info/RECORD +19 -0
- agent_cost_tracker-0.2.1.dist-info/WHEEL +5 -0
- agent_cost_tracker-0.2.1.dist-info/entry_points.txt +2 -0
- agent_cost_tracker-0.2.1.dist-info/licenses/LICENSE +21 -0
- agent_cost_tracker-0.2.1.dist-info/top_level.txt +1 -0
agent_cost/__init__.py
ADDED
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
|