codex-usage-tracking 0.3.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.
Files changed (50) hide show
  1. codex_usage_tracker/__init__.py +7 -0
  2. codex_usage_tracker/__main__.py +6 -0
  3. codex_usage_tracker/allowance.py +759 -0
  4. codex_usage_tracker/api_payloads.py +90 -0
  5. codex_usage_tracker/cli.py +1326 -0
  6. codex_usage_tracker/context.py +410 -0
  7. codex_usage_tracker/costing.py +176 -0
  8. codex_usage_tracker/dashboard.py +389 -0
  9. codex_usage_tracker/diagnostics.py +624 -0
  10. codex_usage_tracker/formatting.py +225 -0
  11. codex_usage_tracker/json_contracts.py +350 -0
  12. codex_usage_tracker/mcp_server.py +371 -0
  13. codex_usage_tracker/models.py +92 -0
  14. codex_usage_tracker/parser.py +491 -0
  15. codex_usage_tracker/paths.py +18 -0
  16. codex_usage_tracker/plugin_data/__init__.py +1 -0
  17. codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
  18. codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
  19. codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
  20. codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
  21. codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
  22. codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
  23. codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
  24. codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
  25. codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
  26. codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
  27. codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
  28. codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
  29. codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
  30. codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
  31. codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
  32. codex_usage_tracker/plugin_installer.py +312 -0
  33. codex_usage_tracker/pricing.py +57 -0
  34. codex_usage_tracker/pricing_config.py +223 -0
  35. codex_usage_tracker/pricing_estimates.py +44 -0
  36. codex_usage_tracker/pricing_openai.py +253 -0
  37. codex_usage_tracker/projects.py +347 -0
  38. codex_usage_tracker/recommendations.py +270 -0
  39. codex_usage_tracker/reports.py +637 -0
  40. codex_usage_tracker/schema.py +71 -0
  41. codex_usage_tracker/server.py +400 -0
  42. codex_usage_tracker/store.py +666 -0
  43. codex_usage_tracker/support.py +147 -0
  44. codex_usage_tracker/threads.py +183 -0
  45. codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
  46. codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
  47. codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
  48. codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
  49. codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
  50. codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,637 @@
1
+ """Shared report application services for CLI and MCP surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from codex_usage_tracker.allowance import (
11
+ annotate_rows_with_allowance,
12
+ load_allowance_config,
13
+ )
14
+ from codex_usage_tracker.formatting import (
15
+ format_calls,
16
+ format_pricing_coverage,
17
+ format_recommendations,
18
+ format_summary,
19
+ )
20
+ from codex_usage_tracker.paths import DEFAULT_PROJECTS_PATH
21
+ from codex_usage_tracker.pricing import (
22
+ PricingConfig,
23
+ annotate_rows_with_efficiency,
24
+ load_pricing_config,
25
+ summarize_pricing_coverage,
26
+ )
27
+ from codex_usage_tracker.projects import (
28
+ annotate_rows_with_project_identity,
29
+ apply_project_privacy_to_rows,
30
+ apply_project_privacy_to_summary_rows,
31
+ load_project_config,
32
+ validate_privacy_mode,
33
+ )
34
+ from codex_usage_tracker.recommendations import annotate_rows_with_recommendations
35
+ from codex_usage_tracker.store import (
36
+ query_dashboard_events,
37
+ query_most_expensive_calls,
38
+ query_summary,
39
+ )
40
+ from codex_usage_tracker.threads import annotate_thread_attachments
41
+
42
+ SUMMARY_GROUP_BY_CHOICES = (
43
+ "date",
44
+ "model",
45
+ "effort",
46
+ "cwd",
47
+ "project",
48
+ "project_tag",
49
+ "thread",
50
+ "session",
51
+ "thread_source",
52
+ "subagent_type",
53
+ "agent_role",
54
+ "parent_session",
55
+ "parent_thread",
56
+ )
57
+ SUMMARY_PRESET_CHOICES = (
58
+ "today",
59
+ "last-7-days",
60
+ "by-model",
61
+ "by-cwd",
62
+ "by-project",
63
+ "by-project-tag",
64
+ "by-thread",
65
+ "by-subagent-role",
66
+ "by-subagent-type",
67
+ "expensive",
68
+ )
69
+ EXPENSIVE_PRESET_CHOICES = ("today", "last-7-days")
70
+ QUERY_PRICING_STATUS_CHOICES = ("priced", "estimated", "unpriced")
71
+ QUERY_CREDIT_CONFIDENCE_CHOICES = ("exact", "estimated", "unpriced", "user_override")
72
+
73
+ _SUMMARY_PRESET_GROUPS = {
74
+ "by-model": "model",
75
+ "by-cwd": "cwd",
76
+ "by-project": "project",
77
+ "by-project-tag": "project_tag",
78
+ "by-thread": "thread",
79
+ "by-subagent-role": "agent_role",
80
+ "by-subagent-type": "subagent_type",
81
+ }
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class SummaryReport:
86
+ """Resolved aggregate usage summary for one display surface."""
87
+
88
+ rows: list[dict[str, Any]]
89
+ group_by: str
90
+ is_expensive: bool = False
91
+ privacy_mode: str = "normal"
92
+
93
+ def render(self) -> str:
94
+ if self.is_expensive:
95
+ return format_calls(self.rows)
96
+ return format_summary(self.rows, self.group_by)
97
+
98
+ def payload(self) -> dict[str, Any]:
99
+ return {
100
+ "schema": "codex-usage-tracker-summary-v1",
101
+ "group_by": self.group_by,
102
+ "is_expensive": self.is_expensive,
103
+ "privacy_mode": self.privacy_mode,
104
+ "row_count": len(self.rows),
105
+ "rows": self.rows,
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class PricingCoverageReport:
111
+ """Resolved pricing coverage report."""
112
+
113
+ payload: dict[str, Any]
114
+
115
+ def render(self, limit: int = 20) -> str:
116
+ return format_pricing_coverage(self.payload, limit=limit)
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class QueryReport:
121
+ """Stable machine-readable aggregate usage query result."""
122
+
123
+ payload: dict[str, Any]
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class RecommendationsReport:
128
+ """Stable recommendation ranking for aggregate usage rows and threads."""
129
+
130
+ payload: dict[str, Any]
131
+
132
+ def render(self) -> str:
133
+ return format_recommendations(self.payload)
134
+
135
+
136
+ def resolve_summary_options(
137
+ group_by: str, preset: str | None, since: str | None
138
+ ) -> tuple[str, str | None]:
139
+ """Resolve summary presets into a group and since filter."""
140
+
141
+ return _SUMMARY_PRESET_GROUPS.get(preset or "", group_by), resolve_since(preset, since)
142
+
143
+
144
+ def resolve_since(preset: str | None, since: str | None) -> str | None:
145
+ """Resolve date presets into an ISO date string."""
146
+
147
+ if since:
148
+ return since
149
+ if preset == "today":
150
+ return date.today().isoformat()
151
+ if preset == "last-7-days":
152
+ return (date.today() - timedelta(days=6)).isoformat()
153
+ return None
154
+
155
+
156
+ def build_summary_report(
157
+ *,
158
+ db_path: Path,
159
+ pricing_path: Path,
160
+ group_by: str = "thread",
161
+ limit: int = 20,
162
+ preset: str | None = None,
163
+ since: str | None = None,
164
+ projects_path: Path = DEFAULT_PROJECTS_PATH,
165
+ privacy_mode: str = "normal",
166
+ ) -> SummaryReport:
167
+ """Build a usage summary or expensive-call preset from aggregate rows."""
168
+
169
+ privacy_mode = validate_privacy_mode(privacy_mode)
170
+ resolved_group_by, since_filter = resolve_summary_options(group_by, preset, since)
171
+ pricing = load_pricing_config(pricing_path)
172
+ if preset == "expensive":
173
+ rows = query_most_expensive_calls(db_path, limit=limit, since=since_filter)
174
+ return SummaryReport(
175
+ rows=apply_project_privacy_to_rows(
176
+ annotate_rows_with_recommendations(
177
+ annotate_rows_with_efficiency(rows, pricing)
178
+ ),
179
+ privacy_mode=privacy_mode,
180
+ ),
181
+ group_by=resolved_group_by,
182
+ is_expensive=True,
183
+ privacy_mode=privacy_mode,
184
+ )
185
+
186
+ if resolved_group_by in {"project", "project_tag"}:
187
+ rows = _project_summary_rows(
188
+ db_path=db_path,
189
+ pricing=pricing,
190
+ group_by=resolved_group_by,
191
+ limit=limit,
192
+ since=since_filter,
193
+ projects_path=projects_path,
194
+ privacy_mode=privacy_mode,
195
+ )
196
+ return SummaryReport(rows=rows, group_by=resolved_group_by, privacy_mode=privacy_mode)
197
+
198
+ rows = query_summary(
199
+ db_path,
200
+ group_by=resolved_group_by,
201
+ limit=limit,
202
+ since=since_filter,
203
+ )
204
+ if resolved_group_by == "model":
205
+ rows = annotate_rows_with_efficiency(rows, pricing, model_field="group_key")
206
+ rows = apply_project_privacy_to_summary_rows(
207
+ rows, group_by=resolved_group_by, privacy_mode=privacy_mode
208
+ )
209
+ return SummaryReport(rows=rows, group_by=resolved_group_by, privacy_mode=privacy_mode)
210
+
211
+
212
+ def build_expensive_calls_report(
213
+ *,
214
+ db_path: Path,
215
+ pricing_path: Path,
216
+ limit: int = 20,
217
+ preset: str | None = None,
218
+ since: str | None = None,
219
+ privacy_mode: str = "normal",
220
+ ) -> SummaryReport:
221
+ """Build a highest-token-call report with pricing efficiency annotations."""
222
+
223
+ privacy_mode = validate_privacy_mode(privacy_mode)
224
+ pricing = load_pricing_config(pricing_path)
225
+ rows = query_most_expensive_calls(
226
+ db_path,
227
+ limit=limit,
228
+ since=resolve_since(preset, since),
229
+ )
230
+ return SummaryReport(
231
+ rows=apply_project_privacy_to_rows(
232
+ annotate_rows_with_recommendations(annotate_rows_with_efficiency(rows, pricing)),
233
+ privacy_mode=privacy_mode,
234
+ ),
235
+ group_by="call",
236
+ is_expensive=True,
237
+ privacy_mode=privacy_mode,
238
+ )
239
+
240
+
241
+ def build_pricing_coverage_report(
242
+ *,
243
+ db_path: Path,
244
+ pricing_path: Path,
245
+ limit: int = 1000,
246
+ since: str | None = None,
247
+ pricing: PricingConfig | None = None,
248
+ ) -> PricingCoverageReport:
249
+ """Build pricing coverage data grouped by model."""
250
+
251
+ config = pricing or load_pricing_config(pricing_path)
252
+ rows = query_summary(db_path, group_by="model", limit=limit, since=since)
253
+ return PricingCoverageReport(summarize_pricing_coverage(rows, pricing=config))
254
+
255
+
256
+ def build_query_report(
257
+ *,
258
+ db_path: Path,
259
+ pricing_path: Path,
260
+ allowance_path: Path,
261
+ projects_path: Path = DEFAULT_PROJECTS_PATH,
262
+ since: str | None = None,
263
+ until: str | None = None,
264
+ model: str | None = None,
265
+ effort: str | None = None,
266
+ thread: str | None = None,
267
+ project: str | None = None,
268
+ pricing_status: str | None = None,
269
+ credit_confidence: str | None = None,
270
+ min_tokens: int | None = None,
271
+ min_credits: float | None = None,
272
+ limit: int = 100,
273
+ privacy_mode: str = "normal",
274
+ ) -> QueryReport:
275
+ """Build a stable JSON usage query with aggregate-only annotated rows."""
276
+
277
+ privacy_mode = validate_privacy_mode(privacy_mode)
278
+ if pricing_status and pricing_status not in QUERY_PRICING_STATUS_CHOICES:
279
+ raise ValueError(
280
+ f"pricing_status must be one of: {', '.join(QUERY_PRICING_STATUS_CHOICES)}"
281
+ )
282
+ if credit_confidence and credit_confidence not in QUERY_CREDIT_CONFIDENCE_CHOICES:
283
+ raise ValueError(
284
+ f"credit_confidence must be one of: {', '.join(QUERY_CREDIT_CONFIDENCE_CHOICES)}"
285
+ )
286
+ rows = annotate_thread_attachments(
287
+ query_dashboard_events(
288
+ db_path,
289
+ limit=0,
290
+ since=since,
291
+ until=until,
292
+ model=model,
293
+ effort=effort,
294
+ thread=thread,
295
+ min_tokens=min_tokens,
296
+ )
297
+ )
298
+ pricing = load_pricing_config(pricing_path)
299
+ allowance = load_allowance_config(allowance_path)
300
+ rows = annotate_rows_with_allowance(annotate_rows_with_efficiency(rows, pricing), allowance)
301
+ rows = annotate_rows_with_recommendations(rows)
302
+ rows = annotate_rows_with_project_identity(rows, load_project_config(projects_path))
303
+ rows = [
304
+ row
305
+ for row in rows
306
+ if _query_row_matches(
307
+ row,
308
+ until=until,
309
+ model=model,
310
+ effort=effort,
311
+ thread=thread,
312
+ project=project,
313
+ pricing_status=pricing_status,
314
+ credit_confidence=credit_confidence,
315
+ min_tokens=min_tokens,
316
+ min_credits=min_credits,
317
+ )
318
+ ]
319
+ rows = apply_project_privacy_to_rows(rows, privacy_mode=privacy_mode)
320
+ normalized_limit = None if limit <= 0 else limit
321
+ limited_rows = rows if normalized_limit is None else rows[:normalized_limit]
322
+ return QueryReport(
323
+ {
324
+ "schema": "codex-usage-tracker-query-v1",
325
+ "filters": {
326
+ "since": since,
327
+ "until": until,
328
+ "model": model,
329
+ "effort": effort,
330
+ "thread": thread,
331
+ "project": project,
332
+ "pricing_status": pricing_status,
333
+ "credit_confidence": credit_confidence,
334
+ "min_tokens": min_tokens,
335
+ "min_credits": min_credits,
336
+ "limit": normalized_limit,
337
+ "privacy_mode": privacy_mode,
338
+ },
339
+ "row_count": len(limited_rows),
340
+ "total_matched_rows": len(rows),
341
+ "truncated": normalized_limit is not None and len(rows) > normalized_limit,
342
+ "rows": limited_rows,
343
+ }
344
+ )
345
+
346
+
347
+ def build_recommendations_report(
348
+ *,
349
+ db_path: Path,
350
+ pricing_path: Path,
351
+ allowance_path: Path,
352
+ projects_path: Path = DEFAULT_PROJECTS_PATH,
353
+ since: str | None = None,
354
+ until: str | None = None,
355
+ model: str | None = None,
356
+ effort: str | None = None,
357
+ thread: str | None = None,
358
+ project: str | None = None,
359
+ min_score: float | None = None,
360
+ limit: int = 20,
361
+ privacy_mode: str = "normal",
362
+ ) -> RecommendationsReport:
363
+ """Build ranked aggregate recommendations for usage investigations."""
364
+
365
+ privacy_mode = validate_privacy_mode(privacy_mode)
366
+ rows = annotate_thread_attachments(
367
+ query_dashboard_events(
368
+ db_path,
369
+ limit=0,
370
+ since=since,
371
+ until=until,
372
+ model=model,
373
+ effort=effort,
374
+ thread=thread,
375
+ )
376
+ )
377
+ pricing = load_pricing_config(pricing_path)
378
+ allowance = load_allowance_config(allowance_path)
379
+ rows = annotate_rows_with_allowance(annotate_rows_with_efficiency(rows, pricing), allowance)
380
+ rows = annotate_rows_with_recommendations(rows)
381
+ rows = annotate_rows_with_project_identity(rows, load_project_config(projects_path))
382
+ scored_rows = [
383
+ row
384
+ for row in rows
385
+ if row.get("action_recommendations")
386
+ and (min_score is None or float(row.get("recommendation_score") or 0) >= min_score)
387
+ and _query_row_matches(
388
+ row,
389
+ until=until,
390
+ model=model,
391
+ effort=effort,
392
+ thread=thread,
393
+ project=project,
394
+ pricing_status=None,
395
+ credit_confidence=None,
396
+ min_tokens=None,
397
+ min_credits=None,
398
+ )
399
+ ]
400
+ scored_rows.sort(key=_recommendation_sort_key)
401
+ normalized_limit = None if limit <= 0 else limit
402
+ limited_rows = scored_rows if normalized_limit is None else scored_rows[:normalized_limit]
403
+ private_rows = apply_project_privacy_to_rows(limited_rows, privacy_mode=privacy_mode)
404
+ return RecommendationsReport(
405
+ {
406
+ "schema": "codex-usage-tracker-recommendations-v1",
407
+ "filters": {
408
+ "since": since,
409
+ "until": until,
410
+ "model": model,
411
+ "effort": effort,
412
+ "thread": thread,
413
+ "project": project,
414
+ "min_score": min_score,
415
+ "limit": normalized_limit,
416
+ "privacy_mode": privacy_mode,
417
+ },
418
+ "row_count": len(private_rows),
419
+ "total_matched_rows": len(scored_rows),
420
+ "truncated": normalized_limit is not None and len(scored_rows) > normalized_limit,
421
+ "threads": _thread_recommendation_rows(scored_rows, limit=normalized_limit or 20),
422
+ "rows": private_rows,
423
+ }
424
+ )
425
+
426
+
427
+ def _recommendation_sort_key(row: dict[str, Any]) -> tuple[float, int, str, str]:
428
+ return (
429
+ -float(row.get("recommendation_score") or 0),
430
+ -int(row.get("total_tokens") or 0),
431
+ str(row.get("event_timestamp") or ""),
432
+ str(row.get("record_id") or ""),
433
+ )
434
+
435
+
436
+ def _thread_recommendation_rows(
437
+ rows: list[dict[str, Any]],
438
+ *,
439
+ limit: int,
440
+ ) -> list[dict[str, Any]]:
441
+ buckets: dict[str, dict[str, Any]] = {}
442
+ for row in rows:
443
+ label = str(
444
+ row.get("thread_attachment_label")
445
+ or row.get("thread_name")
446
+ or row.get("resolved_parent_thread_name")
447
+ or row.get("parent_thread_name")
448
+ or row.get("session_id")
449
+ or "Unknown thread"
450
+ )
451
+ bucket = buckets.setdefault(
452
+ label,
453
+ {
454
+ "thread": label,
455
+ "call_count": 0,
456
+ "session_count": set(),
457
+ "total_tokens": 0,
458
+ "estimated_cost_usd": 0.0,
459
+ "usage_credits": 0.0,
460
+ "recommendation_score": 0.0,
461
+ "max_recommendation_score": 0.0,
462
+ "primary_recommendation": None,
463
+ "secondary_signals": set(),
464
+ "latest_event": "",
465
+ },
466
+ )
467
+ bucket["call_count"] += 1
468
+ bucket["session_count"].add(row.get("session_id"))
469
+ bucket["total_tokens"] += int(row.get("total_tokens") or 0)
470
+ bucket["estimated_cost_usd"] += float(row.get("estimated_cost_usd") or 0)
471
+ bucket["usage_credits"] += float(row.get("usage_credits") or 0)
472
+ score = float(row.get("recommendation_score") or 0)
473
+ bucket["recommendation_score"] += score
474
+ if score > float(bucket["max_recommendation_score"] or 0):
475
+ bucket["max_recommendation_score"] = score
476
+ bucket["primary_recommendation"] = row.get("primary_recommendation")
477
+ if str(row.get("event_timestamp") or "") > str(bucket["latest_event"] or ""):
478
+ bucket["latest_event"] = str(row.get("event_timestamp") or "")
479
+ for signal in row.get("secondary_signals") or []:
480
+ bucket["secondary_signals"].add(signal)
481
+ primary_signal = row.get("primary_signal")
482
+ if primary_signal:
483
+ bucket["secondary_signals"].add(primary_signal)
484
+ summaries: list[dict[str, Any]] = []
485
+ for bucket in buckets.values():
486
+ primary = bucket.get("primary_recommendation")
487
+ primary_key = primary.get("key") if isinstance(primary, dict) else None
488
+ secondary = sorted(str(signal) for signal in bucket["secondary_signals"] if signal)
489
+ if primary_key in secondary:
490
+ secondary.remove(primary_key)
491
+ summaries.append(
492
+ {
493
+ "thread": bucket["thread"],
494
+ "call_count": int(bucket["call_count"]),
495
+ "session_count": len(bucket["session_count"]),
496
+ "total_tokens": int(bucket["total_tokens"]),
497
+ "estimated_cost_usd": round(float(bucket["estimated_cost_usd"]), 6),
498
+ "usage_credits": round(float(bucket["usage_credits"]), 6),
499
+ "recommendation_score": round(float(bucket["recommendation_score"]), 2),
500
+ "max_recommendation_score": round(float(bucket["max_recommendation_score"]), 2),
501
+ "primary_recommendation": primary,
502
+ "secondary_signals": secondary,
503
+ "latest_event": bucket["latest_event"],
504
+ }
505
+ )
506
+ summaries.sort(
507
+ key=lambda row: (
508
+ -float(row["recommendation_score"]),
509
+ -int(row["total_tokens"]),
510
+ str(row["thread"]),
511
+ )
512
+ )
513
+ return summaries[:limit]
514
+
515
+
516
+ def _query_row_matches(
517
+ row: dict[str, Any],
518
+ *,
519
+ until: str | None,
520
+ model: str | None,
521
+ effort: str | None,
522
+ thread: str | None,
523
+ project: str | None,
524
+ pricing_status: str | None,
525
+ credit_confidence: str | None,
526
+ min_tokens: int | None,
527
+ min_credits: float | None,
528
+ ) -> bool:
529
+ if until and str(row.get("event_timestamp") or "") > until:
530
+ return False
531
+ if model and str(row.get("model") or "") != model:
532
+ return False
533
+ if effort and str(row.get("effort") or "") != effort:
534
+ return False
535
+ if thread:
536
+ thread_values = {
537
+ str(row.get("thread_name") or ""),
538
+ str(row.get("parent_thread_name") or ""),
539
+ str(row.get("resolved_parent_thread_name") or ""),
540
+ str(row.get("thread_attachment_label") or ""),
541
+ str(row.get("session_id") or ""),
542
+ }
543
+ if thread not in thread_values:
544
+ return False
545
+ if project:
546
+ project_values = {
547
+ str(row.get("project_name") or ""),
548
+ str(row.get("project_key") or ""),
549
+ str(row.get("project_relative_cwd") or ""),
550
+ }
551
+ if project not in project_values and project not in (row.get("project_tags") or []):
552
+ return False
553
+ if pricing_status == "priced" and not row.get("pricing_model"):
554
+ return False
555
+ if pricing_status == "estimated" and not row.get("pricing_estimated"):
556
+ return False
557
+ if pricing_status == "unpriced" and row.get("pricing_model"):
558
+ return False
559
+ if credit_confidence and row.get("usage_credit_confidence") != credit_confidence:
560
+ return False
561
+ if min_tokens is not None and int(row.get("total_tokens") or 0) < min_tokens:
562
+ return False
563
+ return not (min_credits is not None and float(row.get("usage_credits") or 0) < min_credits)
564
+
565
+
566
+ def _project_summary_rows(
567
+ *,
568
+ db_path: Path,
569
+ pricing: PricingConfig,
570
+ group_by: str,
571
+ limit: int,
572
+ since: str | None,
573
+ projects_path: Path = DEFAULT_PROJECTS_PATH,
574
+ privacy_mode: str = "normal",
575
+ ) -> list[dict[str, Any]]:
576
+ rows = annotate_rows_with_project_identity(
577
+ annotate_rows_with_efficiency(query_dashboard_events(db_path, limit=0, since=since), pricing),
578
+ load_project_config(projects_path),
579
+ )
580
+ rows = apply_project_privacy_to_rows(rows, privacy_mode=privacy_mode)
581
+ buckets: dict[str, dict[str, Any]] = {}
582
+ for row in rows:
583
+ if group_by == "project_tag":
584
+ keys = row.get("project_tags") or ["untagged"]
585
+ else:
586
+ keys = [row.get("project_name") or "Unknown project"]
587
+ for key in keys:
588
+ bucket = buckets.setdefault(
589
+ str(key),
590
+ {
591
+ "group_key": str(key),
592
+ "model_calls": 0,
593
+ "sessions": set(),
594
+ "turns": set(),
595
+ "input_tokens": 0,
596
+ "cached_input_tokens": 0,
597
+ "uncached_input_tokens": 0,
598
+ "output_tokens": 0,
599
+ "reasoning_output_tokens": 0,
600
+ "total_tokens": 0,
601
+ "estimated_cost_usd": 0.0,
602
+ "_cache_ratio_sum": 0.0,
603
+ "_reasoning_ratio_sum": 0.0,
604
+ "_context_sum": 0.0,
605
+ "latest_event": "",
606
+ },
607
+ )
608
+ bucket["model_calls"] += 1
609
+ bucket["sessions"].add(row.get("session_id"))
610
+ if row.get("turn_id"):
611
+ bucket["turns"].add(row.get("turn_id"))
612
+ for token_key in (
613
+ "input_tokens",
614
+ "cached_input_tokens",
615
+ "uncached_input_tokens",
616
+ "output_tokens",
617
+ "reasoning_output_tokens",
618
+ "total_tokens",
619
+ ):
620
+ bucket[token_key] += int(row.get(token_key) or 0)
621
+ bucket["estimated_cost_usd"] += float(row.get("estimated_cost_usd") or 0)
622
+ bucket["_cache_ratio_sum"] += float(row.get("cache_ratio") or 0)
623
+ bucket["_reasoning_ratio_sum"] += float(row.get("reasoning_output_ratio") or 0)
624
+ bucket["_context_sum"] += float(row.get("context_window_percent") or 0)
625
+ if str(row.get("event_timestamp") or "") > bucket["latest_event"]:
626
+ bucket["latest_event"] = str(row.get("event_timestamp") or "")
627
+ summaries: list[dict[str, Any]] = []
628
+ for bucket in buckets.values():
629
+ calls = max(int(bucket["model_calls"]), 1)
630
+ bucket["sessions"] = len(bucket["sessions"])
631
+ bucket["turns"] = len(bucket["turns"])
632
+ bucket["avg_cache_ratio"] = bucket.pop("_cache_ratio_sum") / calls
633
+ bucket["avg_reasoning_output_ratio"] = bucket.pop("_reasoning_ratio_sum") / calls
634
+ bucket["avg_context_window_percent"] = bucket.pop("_context_sum") / calls
635
+ summaries.append(bucket)
636
+ summaries.sort(key=lambda row: (-int(row["total_tokens"]), str(row["group_key"])))
637
+ return summaries[:limit]