code-meter 0.1.0__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.
code_meter/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """code-meter: Local AI coding token usage tracker and cost estimator."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Analytics engine package."""
2
+
3
+ from code_meter.analytics.tokens import TokenAnalytics
4
+ from code_meter.analytics.costs import CostAnalytics
5
+ from code_meter.analytics.reports import ReportGenerator
6
+
7
+ __all__ = ["TokenAnalytics", "CostAnalytics", "ReportGenerator"]
@@ -0,0 +1,43 @@
1
+ """Cost analytics computations."""
2
+
3
+ from typing import List, Optional, Tuple
4
+ from code_meter.models.usage import UsageRecord
5
+ from code_meter.pricing.engine import PricingEngine
6
+
7
+
8
+ class CostAnalytics:
9
+ """Calculates cumulative and per-record estimated costs."""
10
+
11
+ @staticmethod
12
+ def compute_record_costs(
13
+ records: List[UsageRecord],
14
+ pricing_engine: PricingEngine,
15
+ ) -> Tuple[List[Optional[float]], Optional[float], int]:
16
+ """
17
+ Compute cost for each record.
18
+ Returns (costs_list, total_cost_usd, unpriced_count).
19
+ """
20
+ costs: List[Optional[float]] = []
21
+ total_cost: float = 0.0
22
+ has_any_priced = False
23
+ unpriced_count = 0
24
+
25
+ for r in records:
26
+ c = pricing_engine.calculate_cost(
27
+ provider=r.provider,
28
+ model=r.model,
29
+ input_tokens=r.input_tokens,
30
+ output_tokens=r.output_tokens,
31
+ cache_read_tokens=r.cache_read_tokens,
32
+ cache_write_tokens=r.cache_write_tokens,
33
+ timestamp=r.timestamp,
34
+ )
35
+ costs.append(c)
36
+ if c is not None:
37
+ total_cost += c
38
+ has_any_priced = True
39
+ else:
40
+ unpriced_count += 1
41
+
42
+ final_total = round(total_cost, 4) if has_any_priced else None
43
+ return costs, final_total, unpriced_count
@@ -0,0 +1,357 @@
1
+ """Report generator for aggregating records by model, project, session, day, and exporting."""
2
+
3
+ import csv
4
+ import json
5
+ from collections import defaultdict
6
+ from datetime import datetime, timezone
7
+ from io import StringIO
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from code_meter.analytics.costs import CostAnalytics
11
+ from code_meter.analytics.tokens import TokenAnalytics
12
+ from code_meter.models.project import (
13
+ AggregateSummary,
14
+ DailyStats,
15
+ ModelStats,
16
+ ProjectStats,
17
+ SessionStats,
18
+ )
19
+ from code_meter.models.usage import UsageRecord
20
+ from code_meter.pricing.engine import PricingEngine
21
+
22
+
23
+ class ReportGenerator:
24
+ """Generates structured summaries and export formats from raw UsageRecords."""
25
+
26
+ def __init__(self, pricing_engine: PricingEngine):
27
+ self.pricing_engine = pricing_engine
28
+
29
+ def generate_summary(
30
+ self,
31
+ records: List[UsageRecord],
32
+ filter_date_str: Optional[str] = None,
33
+ ) -> AggregateSummary:
34
+ """Build complete AggregateSummary from a list of UsageRecords."""
35
+ if not records:
36
+ return AggregateSummary()
37
+
38
+ inp, out, read, write, total_tokens = TokenAnalytics.calculate_totals(records)
39
+ record_costs, total_cost, unpriced_count = CostAnalytics.compute_record_costs(
40
+ records, self.pricing_engine
41
+ )
42
+
43
+ tokens_per_hr, tokens_per_day, cost_per_hr, cost_per_day = TokenAnalytics.calculate_burn_rate(
44
+ records, total_tokens, total_cost
45
+ )
46
+
47
+ by_model = self.aggregate_by_model(records, record_costs)
48
+ by_project = self.aggregate_by_project(records, record_costs)
49
+
50
+ today_date = filter_date_str or datetime.now(timezone.utc).strftime("%Y-%m-%d")
51
+ today_records_with_costs = [
52
+ (r, c) for r, c in zip(records, record_costs)
53
+ if r.timestamp.strftime("%Y-%m-%d") == today_date
54
+ ]
55
+ today_summary = None
56
+ if today_records_with_costs:
57
+ today_records = [rc[0] for rc in today_records_with_costs]
58
+ t_inp, t_out, t_read, t_write, t_total = TokenAnalytics.calculate_totals(today_records)
59
+ t_costs = [rc[1] for rc in today_records_with_costs if rc[1] is not None]
60
+ t_cost_val = round(sum(t_costs), 4) if t_costs else None
61
+
62
+ today_summary = DailyStats(
63
+ date_str=today_date,
64
+ request_count=len(today_records),
65
+ input_tokens=t_inp,
66
+ output_tokens=t_out,
67
+ cache_read_tokens=t_read,
68
+ cache_write_tokens=t_write,
69
+ total_tokens=t_total,
70
+ estimated_cost_usd=t_cost_val,
71
+ )
72
+
73
+ return AggregateSummary(
74
+ total_requests=len(records),
75
+ input_tokens=inp,
76
+ output_tokens=out,
77
+ cache_read_tokens=read,
78
+ cache_write_tokens=write,
79
+ total_tokens=total_tokens,
80
+ estimated_cost_usd=total_cost,
81
+ unpriced_requests=unpriced_count,
82
+ tokens_per_hour=tokens_per_hr,
83
+ tokens_per_day=tokens_per_day,
84
+ cost_per_hour=cost_per_hr,
85
+ cost_per_day=cost_per_day,
86
+ by_model=by_model,
87
+ by_project=by_project,
88
+ today_summary=today_summary,
89
+ )
90
+
91
+ def aggregate_by_model(
92
+ self, records: List[UsageRecord], record_costs: List[Optional[float]]
93
+ ) -> List[ModelStats]:
94
+ """Aggregate stats grouped by AI model."""
95
+ groups: Dict[str, Dict[str, Any]] = defaultdict(
96
+ lambda: {
97
+ "request_count": 0,
98
+ "input_tokens": 0,
99
+ "output_tokens": 0,
100
+ "cache_read_tokens": 0,
101
+ "cache_write_tokens": 0,
102
+ "cost": 0.0,
103
+ "has_priced": False,
104
+ }
105
+ )
106
+
107
+ for r, cost in zip(records, record_costs):
108
+ g = groups[r.model]
109
+ g["request_count"] += 1
110
+ g["input_tokens"] += r.input_tokens
111
+ g["output_tokens"] += r.output_tokens
112
+ g["cache_read_tokens"] += r.cache_read_tokens
113
+ g["cache_write_tokens"] += r.cache_write_tokens
114
+ if cost is not None:
115
+ g["cost"] += cost
116
+ g["has_priced"] = True
117
+
118
+ result: List[ModelStats] = []
119
+ for model, g in groups.items():
120
+ tot = g["input_tokens"] + g["output_tokens"] + g["cache_read_tokens"] + g["cache_write_tokens"]
121
+ c_val = round(g["cost"], 4) if g["has_priced"] else None
122
+ result.append(
123
+ ModelStats(
124
+ model=model,
125
+ request_count=g["request_count"],
126
+ input_tokens=g["input_tokens"],
127
+ output_tokens=g["output_tokens"],
128
+ cache_read_tokens=g["cache_read_tokens"],
129
+ cache_write_tokens=g["cache_write_tokens"],
130
+ total_tokens=tot,
131
+ estimated_cost_usd=c_val,
132
+ )
133
+ )
134
+
135
+ result.sort(key=lambda x: x.total_tokens, reverse=True)
136
+ return result
137
+
138
+ def aggregate_by_project(
139
+ self, records: List[UsageRecord], record_costs: List[Optional[float]]
140
+ ) -> List[ProjectStats]:
141
+ """Aggregate stats grouped by project/directory."""
142
+ groups: Dict[str, Dict[str, Any]] = defaultdict(
143
+ lambda: {
144
+ "project_path": None,
145
+ "request_count": 0,
146
+ "input_tokens": 0,
147
+ "output_tokens": 0,
148
+ "cache_read_tokens": 0,
149
+ "cache_write_tokens": 0,
150
+ "cost": 0.0,
151
+ "has_priced": False,
152
+ }
153
+ )
154
+
155
+ for r, cost in zip(records, record_costs):
156
+ p_name = r.project_id or "Default/Unknown"
157
+ g = groups[p_name]
158
+ if not g["project_path"] and r.project_path:
159
+ g["project_path"] = r.project_path
160
+ g["request_count"] += 1
161
+ g["input_tokens"] += r.input_tokens
162
+ g["output_tokens"] += r.output_tokens
163
+ g["cache_read_tokens"] += r.cache_read_tokens
164
+ g["cache_write_tokens"] += r.cache_write_tokens
165
+ if cost is not None:
166
+ g["cost"] += cost
167
+ g["has_priced"] = True
168
+
169
+ result: List[ProjectStats] = []
170
+ for p_name, g in groups.items():
171
+ tot = g["input_tokens"] + g["output_tokens"] + g["cache_read_tokens"] + g["cache_write_tokens"]
172
+ c_val = round(g["cost"], 4) if g["has_priced"] else None
173
+ result.append(
174
+ ProjectStats(
175
+ project_name=p_name,
176
+ project_path=g["project_path"],
177
+ request_count=g["request_count"],
178
+ input_tokens=g["input_tokens"],
179
+ output_tokens=g["output_tokens"],
180
+ cache_read_tokens=g["cache_read_tokens"],
181
+ cache_write_tokens=g["cache_write_tokens"],
182
+ total_tokens=tot,
183
+ estimated_cost_usd=c_val,
184
+ )
185
+ )
186
+
187
+ result.sort(key=lambda x: x.total_tokens, reverse=True)
188
+ return result
189
+
190
+ def aggregate_by_session(
191
+ self, records: List[UsageRecord], record_costs: List[Optional[float]]
192
+ ) -> List[SessionStats]:
193
+ """Aggregate stats grouped by session ID."""
194
+ groups: Dict[str, Dict[str, Any]] = defaultdict(
195
+ lambda: {
196
+ "project_name": "Unknown",
197
+ "project_path": None,
198
+ "models": defaultdict(int),
199
+ "request_count": 0,
200
+ "input_tokens": 0,
201
+ "output_tokens": 0,
202
+ "cache_read_tokens": 0,
203
+ "cache_write_tokens": 0,
204
+ "cost": 0.0,
205
+ "has_priced": False,
206
+ "first_seen": None,
207
+ "last_seen": None,
208
+ }
209
+ )
210
+
211
+ for r, cost in zip(records, record_costs):
212
+ s_id = r.session_id or "unknown_session"
213
+ g = groups[s_id]
214
+
215
+ if r.project_id:
216
+ g["project_name"] = r.project_id
217
+ if r.project_path:
218
+ g["project_path"] = r.project_path
219
+
220
+ g["models"][r.model] += 1
221
+ g["request_count"] += 1
222
+ g["input_tokens"] += r.input_tokens
223
+ g["output_tokens"] += r.output_tokens
224
+ g["cache_read_tokens"] += r.cache_read_tokens
225
+ g["cache_write_tokens"] += r.cache_write_tokens
226
+
227
+ if cost is not None:
228
+ g["cost"] += cost
229
+ g["has_priced"] = True
230
+
231
+ ts_str = r.timestamp.isoformat()
232
+ if not g["first_seen"] or ts_str < g["first_seen"]:
233
+ g["first_seen"] = ts_str
234
+ if not g["last_seen"] or ts_str > g["last_seen"]:
235
+ g["last_seen"] = ts_str
236
+
237
+ result: List[SessionStats] = []
238
+ for s_id, g in groups.items():
239
+ primary_model = max(g["models"].items(), key=lambda x: x[1])[0] if g["models"] else "Unknown"
240
+ tot = g["input_tokens"] + g["output_tokens"] + g["cache_read_tokens"] + g["cache_write_tokens"]
241
+ c_val = round(g["cost"], 4) if g["has_priced"] else None
242
+ result.append(
243
+ SessionStats(
244
+ session_id=s_id,
245
+ project_name=g["project_name"],
246
+ project_path=g["project_path"],
247
+ primary_model=primary_model,
248
+ request_count=g["request_count"],
249
+ input_tokens=g["input_tokens"],
250
+ output_tokens=g["output_tokens"],
251
+ cache_read_tokens=g["cache_read_tokens"],
252
+ cache_write_tokens=g["cache_write_tokens"],
253
+ total_tokens=tot,
254
+ estimated_cost_usd=c_val,
255
+ first_seen=g["first_seen"],
256
+ last_seen=g["last_seen"],
257
+ )
258
+ )
259
+
260
+ result.sort(key=lambda x: x.last_seen or "", reverse=True)
261
+ return result
262
+
263
+ def aggregate_by_day(
264
+ self, records: List[UsageRecord], record_costs: List[Optional[float]]
265
+ ) -> List[DailyStats]:
266
+ """Aggregate stats grouped by day (YYYY-MM-DD)."""
267
+ groups: Dict[str, Dict[str, Any]] = defaultdict(
268
+ lambda: {
269
+ "request_count": 0,
270
+ "input_tokens": 0,
271
+ "output_tokens": 0,
272
+ "cache_read_tokens": 0,
273
+ "cache_write_tokens": 0,
274
+ "cost": 0.0,
275
+ "has_priced": False,
276
+ }
277
+ )
278
+
279
+ for r, cost in zip(records, record_costs):
280
+ d_str = r.timestamp.strftime("%Y-%m-%d")
281
+ g = groups[d_str]
282
+ g["request_count"] += 1
283
+ g["input_tokens"] += r.input_tokens
284
+ g["output_tokens"] += r.output_tokens
285
+ g["cache_read_tokens"] += r.cache_read_tokens
286
+ g["cache_write_tokens"] += r.cache_write_tokens
287
+ if cost is not None:
288
+ g["cost"] += cost
289
+ g["has_priced"] = True
290
+
291
+ result: List[DailyStats] = []
292
+ for d_str, g in groups.items():
293
+ tot = g["input_tokens"] + g["output_tokens"] + g["cache_read_tokens"] + g["cache_write_tokens"]
294
+ c_val = round(g["cost"], 4) if g["has_priced"] else None
295
+ result.append(
296
+ DailyStats(
297
+ date_str=d_str,
298
+ request_count=g["request_count"],
299
+ input_tokens=g["input_tokens"],
300
+ output_tokens=g["output_tokens"],
301
+ cache_read_tokens=g["cache_read_tokens"],
302
+ cache_write_tokens=g["cache_write_tokens"],
303
+ total_tokens=tot,
304
+ estimated_cost_usd=c_val,
305
+ )
306
+ )
307
+
308
+ result.sort(key=lambda x: x.date_str, reverse=True)
309
+ return result
310
+
311
+ def export_records(self, records: List[UsageRecord], fmt: str) -> str:
312
+ """Export UsageRecords to CSV or JSON format string."""
313
+ record_costs, _, _ = CostAnalytics.compute_record_costs(records, self.pricing_engine)
314
+
315
+ if fmt.lower() == "json":
316
+ export_list = []
317
+ for r, cost in zip(records, record_costs):
318
+ item = r.model_dump()
319
+ item["timestamp"] = r.timestamp.isoformat()
320
+ item["estimated_cost_usd"] = cost
321
+ export_list.append(item)
322
+ return json.dumps(export_list, indent=2)
323
+
324
+ output = StringIO()
325
+ writer = csv.writer(output)
326
+ writer.writerow([
327
+ "timestamp",
328
+ "provider",
329
+ "request_id",
330
+ "session_id",
331
+ "project",
332
+ "project_path",
333
+ "model",
334
+ "input_tokens",
335
+ "output_tokens",
336
+ "cache_read_tokens",
337
+ "cache_write_tokens",
338
+ "estimated_cost_usd",
339
+ ])
340
+
341
+ for r, cost in zip(records, record_costs):
342
+ writer.writerow([
343
+ r.timestamp.isoformat(),
344
+ r.provider,
345
+ r.request_id or "",
346
+ r.session_id or "",
347
+ r.project_id or "",
348
+ r.project_path or "",
349
+ r.model,
350
+ r.input_tokens,
351
+ r.output_tokens,
352
+ r.cache_read_tokens,
353
+ r.cache_write_tokens,
354
+ f"{cost:.6f}" if cost is not None else "",
355
+ ])
356
+
357
+ return output.getvalue()
@@ -0,0 +1,51 @@
1
+ """Token analytics calculations (totals, burn rates, averages)."""
2
+
3
+ from typing import List, Optional, Tuple
4
+ from code_meter.models.usage import UsageRecord
5
+
6
+
7
+ class TokenAnalytics:
8
+ """Utility methods for calculating token totals and burn rates."""
9
+
10
+ @staticmethod
11
+ def calculate_totals(records: List[UsageRecord]) -> Tuple[int, int, int, int, int]:
12
+ """Return (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens)."""
13
+ inp = sum(r.input_tokens for r in records)
14
+ out = sum(r.output_tokens for r in records)
15
+ read = sum(r.cache_read_tokens for r in records)
16
+ write = sum(r.cache_write_tokens for r in records)
17
+ total = inp + out + read + write
18
+ return inp, out, read, write, total
19
+
20
+ @staticmethod
21
+ def calculate_burn_rate(
22
+ records: List[UsageRecord],
23
+ total_tokens: int,
24
+ total_cost: Optional[float],
25
+ ) -> Tuple[Optional[float], Optional[float], Optional[float], Optional[float]]:
26
+ """
27
+ Calculate burn rates:
28
+ Returns (tokens_per_hour, tokens_per_day, cost_per_hour, cost_per_day).
29
+ Returns None for values if there are fewer than 2 records or duration is 0.
30
+ """
31
+ if len(records) < 2:
32
+ return None, None, None, None
33
+
34
+ sorted_records = sorted(records, key=lambda r: r.timestamp)
35
+ first_ts = sorted_records[0].timestamp
36
+ last_ts = sorted_records[-1].timestamp
37
+
38
+ duration_seconds = (last_ts - first_ts).total_seconds()
39
+ if duration_seconds <= 60:
40
+ return None, None, None, None
41
+
42
+ duration_hours = duration_seconds / 3600.0
43
+ duration_days = duration_seconds / 86400.0
44
+
45
+ tokens_per_hour = round(total_tokens / duration_hours, 2)
46
+ tokens_per_day = round(total_tokens / duration_days, 2)
47
+
48
+ cost_per_hour = round(total_cost / duration_hours, 4) if total_cost is not None else None
49
+ cost_per_day = round(total_cost / duration_days, 4) if total_cost is not None else None
50
+
51
+ return tokens_per_hour, tokens_per_day, cost_per_hour, cost_per_day