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 +3 -0
- code_meter/analytics/__init__.py +7 -0
- code_meter/analytics/costs.py +43 -0
- code_meter/analytics/reports.py +357 -0
- code_meter/analytics/tokens.py +51 -0
- code_meter/cli.py +334 -0
- code_meter/config.py +116 -0
- code_meter/models/__init__.py +13 -0
- code_meter/models/project.py +85 -0
- code_meter/models/usage.py +61 -0
- code_meter/pricing/__init__.py +14 -0
- code_meter/pricing/anthropic.py +78 -0
- code_meter/pricing/engine.py +97 -0
- code_meter/pricing/google.py +60 -0
- code_meter/pricing/openai.py +69 -0
- code_meter/providers/__init__.py +15 -0
- code_meter/providers/antigravity.py +358 -0
- code_meter/providers/base.py +42 -0
- code_meter/providers/claude_code.py +378 -0
- code_meter/providers/codex.py +356 -0
- code_meter/storage/__init__.py +6 -0
- code_meter/storage/database.py +147 -0
- code_meter/storage/repository.py +482 -0
- code_meter/ui/__init__.py +19 -0
- code_meter/ui/dashboard.py +111 -0
- code_meter/ui/tables.py +195 -0
- code_meter/watcher.py +56 -0
- code_meter-0.1.0.dist-info/METADATA +207 -0
- code_meter-0.1.0.dist-info/RECORD +33 -0
- code_meter-0.1.0.dist-info/WHEEL +5 -0
- code_meter-0.1.0.dist-info/entry_points.txt +6 -0
- code_meter-0.1.0.dist-info/licenses/LICENSE +21 -0
- code_meter-0.1.0.dist-info/top_level.txt +1 -0
code_meter/cli.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
|
|
2
|
+
"""Typer CLI interface for code-meter."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from code_meter.analytics.reports import ReportGenerator
|
|
13
|
+
from code_meter.config import get_config_file_path, load_config, save_config
|
|
14
|
+
from code_meter.pricing.engine import PricingEngine
|
|
15
|
+
from code_meter.providers.antigravity import AntigravityProvider
|
|
16
|
+
from code_meter.providers.claude_code import ClaudeCodeProvider
|
|
17
|
+
from code_meter.providers.codex import CodexProvider
|
|
18
|
+
from code_meter.storage.database import Database
|
|
19
|
+
from code_meter.storage.repository import UsageRepository
|
|
20
|
+
from code_meter.ui.dashboard import render_dashboard
|
|
21
|
+
from code_meter.ui.tables import (
|
|
22
|
+
render_daily_table,
|
|
23
|
+
render_models_table,
|
|
24
|
+
render_projects_table,
|
|
25
|
+
render_sessions_table,
|
|
26
|
+
render_status_panel,
|
|
27
|
+
)
|
|
28
|
+
from code_meter.watcher import run_watch_loop
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="code-meter",
|
|
32
|
+
help="Local AI coding token usage tracker and cost estimator.",
|
|
33
|
+
add_completion=False,
|
|
34
|
+
invoke_without_command=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_services():
|
|
41
|
+
"""Helper to initialize config, database, repository, pricing engine, and providers."""
|
|
42
|
+
config = load_config()
|
|
43
|
+
db_path = config.get_db_path()
|
|
44
|
+
claude_dir = config.get_claude_dir()
|
|
45
|
+
codex_dir = config.get_codex_dir()
|
|
46
|
+
antigravity_dir = config.get_antigravity_dir()
|
|
47
|
+
|
|
48
|
+
db = Database(db_path)
|
|
49
|
+
repository = UsageRepository(db)
|
|
50
|
+
pricing_engine = PricingEngine(repository)
|
|
51
|
+
|
|
52
|
+
providers = [
|
|
53
|
+
ClaudeCodeProvider(claude_dir),
|
|
54
|
+
CodexProvider(codex_dir),
|
|
55
|
+
AntigravityProvider(antigravity_dir),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
report_gen = ReportGenerator(pricing_engine)
|
|
59
|
+
|
|
60
|
+
return config, repository, pricing_engine, providers, report_gen
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _perform_scan(quiet: bool = False) -> int:
|
|
64
|
+
"""Execute scan across all AI providers and return number of new records saved."""
|
|
65
|
+
config, repo, pricing_engine, providers, _ = _get_services()
|
|
66
|
+
existing_states = repo.get_scan_states()
|
|
67
|
+
|
|
68
|
+
total_new_records = 0
|
|
69
|
+
total_files_scanned = 0
|
|
70
|
+
|
|
71
|
+
for provider in providers:
|
|
72
|
+
result = provider.scan(existing_states)
|
|
73
|
+
new_records_count = repo.save_records(result.records)
|
|
74
|
+
repo.save_prompts(result.prompts)
|
|
75
|
+
repo.save_code_changes(result.code_changes)
|
|
76
|
+
repo.update_scan_states(result.updated_states)
|
|
77
|
+
|
|
78
|
+
total_new_records += new_records_count
|
|
79
|
+
total_files_scanned += result.files_scanned
|
|
80
|
+
|
|
81
|
+
if not quiet:
|
|
82
|
+
console.print(
|
|
83
|
+
f"[bold green]Scan complete:[/bold green] Scanned {total_files_scanned} files across "
|
|
84
|
+
f"{len(providers)} providers, added {total_new_records} new records."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return total_new_records
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.callback()
|
|
91
|
+
def main_callback(ctx: typer.Context):
|
|
92
|
+
"""Default action when running `code-meter` without subcommands."""
|
|
93
|
+
if ctx.invoked_subcommand is None:
|
|
94
|
+
_perform_scan(quiet=True)
|
|
95
|
+
config, repo, pricing_engine, providers, report_gen = _get_services()
|
|
96
|
+
|
|
97
|
+
records = repo.query_usage_events()
|
|
98
|
+
if not records:
|
|
99
|
+
console.print("[yellow]No usage data available yet.[/yellow]")
|
|
100
|
+
console.print("[dim]Scanned providers: Claude Code, Codex, Antigravity[/dim]")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
summary = report_gen.generate_summary(records, filter_date_str=datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
104
|
+
console.clear()
|
|
105
|
+
render_dashboard(summary, title_suffix="Overview", console=console)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command("scan")
|
|
109
|
+
def scan_cmd():
|
|
110
|
+
"""Scan local Claude Code directories for new token usage records."""
|
|
111
|
+
_perform_scan(quiet=False)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("today")
|
|
115
|
+
def today_cmd():
|
|
116
|
+
"""Display usage statistics for today."""
|
|
117
|
+
_perform_scan(quiet=True)
|
|
118
|
+
_, repo, _, _, report_gen = _get_services()
|
|
119
|
+
|
|
120
|
+
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
121
|
+
records = repo.query_usage_events(start_date=today_start)
|
|
122
|
+
|
|
123
|
+
if not records:
|
|
124
|
+
console.print("[yellow]No usage recorded today.[/yellow]")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
summary = report_gen.generate_summary(records, filter_date_str=today_start.strftime("%Y-%m-%d"))
|
|
128
|
+
render_dashboard(summary, title_suffix="Today", console=console)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("week")
|
|
132
|
+
def week_cmd():
|
|
133
|
+
"""Display usage statistics for the past 7 days."""
|
|
134
|
+
_perform_scan(quiet=True)
|
|
135
|
+
_, repo, _, _, report_gen = _get_services()
|
|
136
|
+
|
|
137
|
+
week_start = datetime.now(timezone.utc) - timedelta(days=7)
|
|
138
|
+
records = repo.query_usage_events(start_date=week_start)
|
|
139
|
+
|
|
140
|
+
if not records:
|
|
141
|
+
console.print("[yellow]No usage recorded in the last 7 days.[/yellow]")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
summary = report_gen.generate_summary(records, filter_date_str=datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
145
|
+
render_dashboard(summary, title_suffix="Last 7 Days", console=console)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("month")
|
|
149
|
+
def month_cmd():
|
|
150
|
+
"""Display usage statistics for the past 30 days."""
|
|
151
|
+
_perform_scan(quiet=True)
|
|
152
|
+
_, repo, _, _, report_gen = _get_services()
|
|
153
|
+
|
|
154
|
+
month_start = datetime.now(timezone.utc) - timedelta(days=30)
|
|
155
|
+
records = repo.query_usage_events(start_date=month_start)
|
|
156
|
+
|
|
157
|
+
if not records:
|
|
158
|
+
console.print("[yellow]No usage recorded in the last 30 days.[/yellow]")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
summary = report_gen.generate_summary(records, filter_date_str=datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
162
|
+
render_dashboard(summary, title_suffix="Last 30 Days", console=console)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command("status")
|
|
166
|
+
def status_cmd():
|
|
167
|
+
"""Display system status (DB path, file counts, record totals)."""
|
|
168
|
+
_perform_scan(quiet=True)
|
|
169
|
+
_, repo, _, _, _ = _get_services()
|
|
170
|
+
summary = repo.get_status_summary()
|
|
171
|
+
render_status_panel(summary, console=console)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command("report")
|
|
175
|
+
def report_cmd(
|
|
176
|
+
model: Optional[str] = typer.Option(None, "--model", help="Filter by model name"),
|
|
177
|
+
project: Optional[str] = typer.Option(None, "--project", help="Filter by project ID or path"),
|
|
178
|
+
provider: Optional[str] = typer.Option(None, "--provider", help="Filter by provider"),
|
|
179
|
+
days: Optional[int] = typer.Option(None, "--days", help="Number of days to include"),
|
|
180
|
+
):
|
|
181
|
+
"""Display detailed aggregated report with optional filters."""
|
|
182
|
+
_perform_scan(quiet=True)
|
|
183
|
+
_, repo, pricing_engine, _, report_gen = _get_services()
|
|
184
|
+
|
|
185
|
+
start_date = datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
|
186
|
+
records = repo.query_usage_events(
|
|
187
|
+
provider=provider,
|
|
188
|
+
model=model,
|
|
189
|
+
project_id=project,
|
|
190
|
+
start_date=start_date,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if not records:
|
|
194
|
+
console.print("[yellow]No matching usage records found.[/yellow]")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
record_costs_list = [
|
|
198
|
+
pricing_engine.calculate_cost(
|
|
199
|
+
r.provider, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_write_tokens, r.timestamp
|
|
200
|
+
)
|
|
201
|
+
for r in records
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
daily_stats = report_gen.aggregate_by_day(records, record_costs_list)
|
|
205
|
+
render_daily_table(daily_stats, console=console)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command("models")
|
|
209
|
+
def models_cmd():
|
|
210
|
+
"""Display token usage and cost aggregated by model."""
|
|
211
|
+
_perform_scan(quiet=True)
|
|
212
|
+
_, repo, pricing_engine, _, report_gen = _get_services()
|
|
213
|
+
records = repo.query_usage_events()
|
|
214
|
+
if not records:
|
|
215
|
+
console.print("[yellow]No usage records found.[/yellow]")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
costs = [
|
|
219
|
+
pricing_engine.calculate_cost(
|
|
220
|
+
r.provider, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_write_tokens, r.timestamp
|
|
221
|
+
)
|
|
222
|
+
for r in records
|
|
223
|
+
]
|
|
224
|
+
model_stats = report_gen.aggregate_by_model(records, costs)
|
|
225
|
+
render_models_table(model_stats, console=console)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command("projects")
|
|
229
|
+
def projects_cmd():
|
|
230
|
+
"""Display token usage and cost aggregated by project/directory."""
|
|
231
|
+
_perform_scan(quiet=True)
|
|
232
|
+
_, repo, pricing_engine, _, report_gen = _get_services()
|
|
233
|
+
records = repo.query_usage_events()
|
|
234
|
+
if not records:
|
|
235
|
+
console.print("[yellow]No usage records found.[/yellow]")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
costs = [
|
|
239
|
+
pricing_engine.calculate_cost(
|
|
240
|
+
r.provider, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_write_tokens, r.timestamp
|
|
241
|
+
)
|
|
242
|
+
for r in records
|
|
243
|
+
]
|
|
244
|
+
project_stats = report_gen.aggregate_by_project(records, costs)
|
|
245
|
+
render_projects_table(project_stats, console=console)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command("sessions")
|
|
249
|
+
def sessions_cmd():
|
|
250
|
+
"""Display token usage and cost aggregated by session ID."""
|
|
251
|
+
_perform_scan(quiet=True)
|
|
252
|
+
_, repo, pricing_engine, _, report_gen = _get_services()
|
|
253
|
+
records = repo.query_usage_events()
|
|
254
|
+
if not records:
|
|
255
|
+
console.print("[yellow]No usage records found.[/yellow]")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
costs = [
|
|
259
|
+
pricing_engine.calculate_cost(
|
|
260
|
+
r.provider, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_write_tokens, r.timestamp
|
|
261
|
+
)
|
|
262
|
+
for r in records
|
|
263
|
+
]
|
|
264
|
+
session_stats = report_gen.aggregate_by_session(records, costs)
|
|
265
|
+
render_sessions_table(session_stats, console=console)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@app.command("export")
|
|
269
|
+
def export_cmd(
|
|
270
|
+
format: str = typer.Option("csv", "--format", "-f", help="Export format: csv or json"),
|
|
271
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
272
|
+
days: Optional[int] = typer.Option(None, "--days", help="Number of days to include"),
|
|
273
|
+
model: Optional[str] = typer.Option(None, "--model", help="Filter by model"),
|
|
274
|
+
project: Optional[str] = typer.Option(None, "--project", help="Filter by project"),
|
|
275
|
+
):
|
|
276
|
+
"""Export usage records to CSV or JSON format."""
|
|
277
|
+
_perform_scan(quiet=True)
|
|
278
|
+
_, repo, _, _, report_gen = _get_services()
|
|
279
|
+
|
|
280
|
+
start_date = datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
|
281
|
+
records = repo.query_usage_events(model=model, project_id=project, start_date=start_date)
|
|
282
|
+
|
|
283
|
+
if not records:
|
|
284
|
+
console.print("[yellow]No matching records to export.[/yellow]")
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
content = report_gen.export_records(records, format)
|
|
288
|
+
|
|
289
|
+
if output:
|
|
290
|
+
output = Path(output).resolve()
|
|
291
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
output.write_text(content, encoding="utf-8")
|
|
293
|
+
console.print(f"[bold green]Exported {len(records)} records to:[/bold green] {output}")
|
|
294
|
+
else:
|
|
295
|
+
sys.stdout.write(content)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.command("config")
|
|
299
|
+
def config_cmd():
|
|
300
|
+
"""Display current configuration file path and contents."""
|
|
301
|
+
config_file = get_config_file_path()
|
|
302
|
+
config = load_config()
|
|
303
|
+
console.print(f"[bold cyan]Configuration File:[/bold cyan] {config_file}\n")
|
|
304
|
+
console.print(f"[bold white]General DB Path:[/bold white] {config.get_db_path()}")
|
|
305
|
+
console.print(f"[bold white]Claude Directory:[/bold white] {config.get_claude_dir()}")
|
|
306
|
+
console.print(f"[bold white]Codex Directory:[/bold white] {config.get_codex_dir()}")
|
|
307
|
+
console.print(f"[bold white]Antigravity Directory:[/bold white] {config.get_antigravity_dir()}")
|
|
308
|
+
console.print(f"[bold white]Refresh Seconds:[/bold white] {config.get_refresh_seconds()}")
|
|
309
|
+
console.print(f"[bold white]Currency:[/bold white] {config.pricing.currency}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@app.command("watch")
|
|
313
|
+
def watch_cmd():
|
|
314
|
+
"""Monitor local session log files in real-time and auto-update metrics."""
|
|
315
|
+
config, repo, pricing_engine, providers, report_gen = _get_services()
|
|
316
|
+
|
|
317
|
+
def update_and_render():
|
|
318
|
+
_perform_scan(quiet=True)
|
|
319
|
+
records = repo.query_usage_events()
|
|
320
|
+
summary = report_gen.generate_summary(records, filter_date_str=datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
321
|
+
console.clear()
|
|
322
|
+
render_dashboard(summary, title_suffix="Watch Mode (Live)", console=console)
|
|
323
|
+
|
|
324
|
+
target_dir = providers[0].claude_dir if providers else config.get_claude_dir()
|
|
325
|
+
run_watch_loop(
|
|
326
|
+
target_dir=target_dir,
|
|
327
|
+
on_change_callback=update_and_render,
|
|
328
|
+
refresh_seconds=config.get_refresh_seconds(),
|
|
329
|
+
console=console,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
app()
|
code_meter/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Configuration management for code-meter."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ImportError:
|
|
11
|
+
import tomli as tomllib # type: ignore
|
|
12
|
+
|
|
13
|
+
import tomli_w
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GeneralConfig(BaseModel):
|
|
17
|
+
database: str = "~/.local/share/code-meter/usage.db"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClaudeCodeConfig(BaseModel):
|
|
21
|
+
directory: str = "~/.claude"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CodexConfig(BaseModel):
|
|
25
|
+
directory: str = "~/.codex"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AntigravityConfig(BaseModel):
|
|
29
|
+
directory: str = "~/.gemini/antigravity-ide"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UIConfig(BaseModel):
|
|
33
|
+
refresh_seconds: int = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PricingConfig(BaseModel):
|
|
37
|
+
currency: str = "USD"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Config(BaseModel):
|
|
41
|
+
general: GeneralConfig = Field(default_factory=GeneralConfig)
|
|
42
|
+
claude_code: ClaudeCodeConfig = Field(default_factory=ClaudeCodeConfig)
|
|
43
|
+
codex: CodexConfig = Field(default_factory=CodexConfig)
|
|
44
|
+
antigravity: AntigravityConfig = Field(default_factory=AntigravityConfig)
|
|
45
|
+
ui: UIConfig = Field(default_factory=UIConfig)
|
|
46
|
+
pricing: PricingConfig = Field(default_factory=PricingConfig)
|
|
47
|
+
|
|
48
|
+
def get_db_path(self) -> Path:
|
|
49
|
+
raw_db = os.environ.get("CODE_METER_DB", os.environ.get("CLAUDE_METER_DB", self.general.database))
|
|
50
|
+
path = Path(os.path.expanduser(raw_db)).resolve()
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
def get_claude_dir(self) -> Path:
|
|
55
|
+
raw_dir = os.environ.get("CODE_METER_CLAUDE_DIR", os.environ.get("CLAUDE_METER_CLAUDE_DIR", self.claude_code.directory))
|
|
56
|
+
return Path(os.path.expanduser(raw_dir)).resolve()
|
|
57
|
+
|
|
58
|
+
def get_codex_dir(self) -> Path:
|
|
59
|
+
raw_dir = os.environ.get("CODE_METER_CODEX_DIR", self.codex.directory)
|
|
60
|
+
return Path(os.path.expanduser(raw_dir)).resolve()
|
|
61
|
+
|
|
62
|
+
def get_antigravity_dir(self) -> Path:
|
|
63
|
+
raw_dir = os.environ.get("CODE_METER_ANTIGRAVITY_DIR", self.antigravity.directory)
|
|
64
|
+
return Path(os.path.expanduser(raw_dir)).resolve()
|
|
65
|
+
|
|
66
|
+
def get_refresh_seconds(self) -> int:
|
|
67
|
+
env_val = os.environ.get("CODE_METER_REFRESH", os.environ.get("CLAUDE_METER_REFRESH"))
|
|
68
|
+
if env_val:
|
|
69
|
+
try:
|
|
70
|
+
return int(env_val)
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
return self.ui.refresh_seconds
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_config_dir() -> Path:
|
|
77
|
+
"""Get platform-appropriate config directory (~/.config/code-meter or %APPDATA%/code-meter)."""
|
|
78
|
+
if os.name == "nt":
|
|
79
|
+
base = os.environ.get("APPDATA")
|
|
80
|
+
if base:
|
|
81
|
+
path = Path(base) / "code-meter"
|
|
82
|
+
else:
|
|
83
|
+
path = Path.home() / ".config" / "code-meter"
|
|
84
|
+
else:
|
|
85
|
+
path = Path.home() / ".config" / "code-meter"
|
|
86
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_config_file_path() -> Path:
|
|
91
|
+
return get_config_dir() / "config.toml"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_config() -> Config:
|
|
95
|
+
"""Load configuration from config.toml, returning default config if not present."""
|
|
96
|
+
config_file = get_config_file_path()
|
|
97
|
+
if not config_file.exists():
|
|
98
|
+
config = Config()
|
|
99
|
+
save_config(config)
|
|
100
|
+
return config
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with open(config_file, "rb") as f:
|
|
104
|
+
data = tomllib.load(f)
|
|
105
|
+
return Config(**data)
|
|
106
|
+
except Exception:
|
|
107
|
+
return Config()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def save_config(config: Config) -> Path:
|
|
111
|
+
"""Save configuration to config.toml."""
|
|
112
|
+
config_file = get_config_file_path()
|
|
113
|
+
data = config.model_dump()
|
|
114
|
+
with open(config_file, "wb") as f:
|
|
115
|
+
tomli_w.dump(data, f)
|
|
116
|
+
return config_file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Data models for code-meter."""
|
|
2
|
+
|
|
3
|
+
from code_meter.models.usage import UsageRecord
|
|
4
|
+
from code_meter.models.project import ProjectStats, ModelStats, SessionStats, DailyStats, AggregateSummary
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"UsageRecord",
|
|
8
|
+
"ProjectStats",
|
|
9
|
+
"ModelStats",
|
|
10
|
+
"SessionStats",
|
|
11
|
+
"DailyStats",
|
|
12
|
+
"AggregateSummary",
|
|
13
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Analytics summary models for reporting and UI."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelStats(BaseModel):
|
|
8
|
+
"""Token usage and cost aggregated by model."""
|
|
9
|
+
|
|
10
|
+
model: str
|
|
11
|
+
request_count: int = 0
|
|
12
|
+
input_tokens: int = 0
|
|
13
|
+
output_tokens: int = 0
|
|
14
|
+
cache_read_tokens: int = 0
|
|
15
|
+
cache_write_tokens: int = 0
|
|
16
|
+
total_tokens: int = 0
|
|
17
|
+
estimated_cost_usd: Optional[float] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectStats(BaseModel):
|
|
21
|
+
"""Token usage and cost aggregated by project/directory."""
|
|
22
|
+
|
|
23
|
+
project_name: str
|
|
24
|
+
project_path: Optional[str] = None
|
|
25
|
+
request_count: int = 0
|
|
26
|
+
input_tokens: int = 0
|
|
27
|
+
output_tokens: int = 0
|
|
28
|
+
cache_read_tokens: int = 0
|
|
29
|
+
cache_write_tokens: int = 0
|
|
30
|
+
total_tokens: int = 0
|
|
31
|
+
estimated_cost_usd: Optional[float] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionStats(BaseModel):
|
|
35
|
+
"""Token usage and cost aggregated by session ID."""
|
|
36
|
+
|
|
37
|
+
session_id: str
|
|
38
|
+
project_name: str = "Unknown"
|
|
39
|
+
project_path: Optional[str] = None
|
|
40
|
+
primary_model: str = "Unknown"
|
|
41
|
+
request_count: int = 0
|
|
42
|
+
input_tokens: int = 0
|
|
43
|
+
output_tokens: int = 0
|
|
44
|
+
cache_read_tokens: int = 0
|
|
45
|
+
cache_write_tokens: int = 0
|
|
46
|
+
total_tokens: int = 0
|
|
47
|
+
estimated_cost_usd: Optional[float] = None
|
|
48
|
+
first_seen: Optional[str] = None
|
|
49
|
+
last_seen: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DailyStats(BaseModel):
|
|
53
|
+
"""Token usage and cost aggregated by date (YYYY-MM-DD)."""
|
|
54
|
+
|
|
55
|
+
date_str: str
|
|
56
|
+
request_count: int = 0
|
|
57
|
+
input_tokens: int = 0
|
|
58
|
+
output_tokens: int = 0
|
|
59
|
+
cache_read_tokens: int = 0
|
|
60
|
+
cache_write_tokens: int = 0
|
|
61
|
+
total_tokens: int = 0
|
|
62
|
+
estimated_cost_usd: Optional[float] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AggregateSummary(BaseModel):
|
|
66
|
+
"""Overall token usage, cost, and burn rate summary."""
|
|
67
|
+
|
|
68
|
+
total_requests: int = 0
|
|
69
|
+
input_tokens: int = 0
|
|
70
|
+
output_tokens: int = 0
|
|
71
|
+
cache_read_tokens: int = 0
|
|
72
|
+
cache_write_tokens: int = 0
|
|
73
|
+
total_tokens: int = 0
|
|
74
|
+
|
|
75
|
+
estimated_cost_usd: Optional[float] = None
|
|
76
|
+
unpriced_requests: int = 0
|
|
77
|
+
|
|
78
|
+
tokens_per_hour: Optional[float] = None
|
|
79
|
+
tokens_per_day: Optional[float] = None
|
|
80
|
+
cost_per_hour: Optional[float] = None
|
|
81
|
+
cost_per_day: Optional[float] = None
|
|
82
|
+
|
|
83
|
+
by_model: List[ModelStats] = Field(default_factory=list)
|
|
84
|
+
by_project: List[ProjectStats] = Field(default_factory=list)
|
|
85
|
+
today_summary: Optional[DailyStats] = None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Normalized UsageRecord, SessionPrompt, and SessionCodeChange models."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UsageRecord(BaseModel):
|
|
9
|
+
"""Normalized usage event from any AI provider."""
|
|
10
|
+
|
|
11
|
+
provider: str
|
|
12
|
+
request_id: Optional[str] = None
|
|
13
|
+
session_id: Optional[str] = None
|
|
14
|
+
timestamp: datetime
|
|
15
|
+
|
|
16
|
+
user_id: Optional[str] = None
|
|
17
|
+
project_id: Optional[str] = None
|
|
18
|
+
project_path: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
model: str
|
|
21
|
+
|
|
22
|
+
input_tokens: int = 0
|
|
23
|
+
output_tokens: int = 0
|
|
24
|
+
|
|
25
|
+
cache_read_tokens: int = 0
|
|
26
|
+
cache_write_tokens: int = 0
|
|
27
|
+
|
|
28
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
# Agent-level tracking readiness
|
|
31
|
+
agent_id: Optional[str] = None
|
|
32
|
+
task_id: Optional[str] = None
|
|
33
|
+
tool_name: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def total_tokens(self) -> int:
|
|
37
|
+
return self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionPrompt(BaseModel):
|
|
41
|
+
"""Record of a user prompt asked during an AI session."""
|
|
42
|
+
|
|
43
|
+
provider: str
|
|
44
|
+
session_id: str
|
|
45
|
+
prompt_id: str
|
|
46
|
+
timestamp: datetime
|
|
47
|
+
prompt_text: str
|
|
48
|
+
project_id: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SessionCodeChange(BaseModel):
|
|
52
|
+
"""Record of code changes / diffs made during an AI session."""
|
|
53
|
+
|
|
54
|
+
provider: str
|
|
55
|
+
session_id: str
|
|
56
|
+
change_id: str
|
|
57
|
+
timestamp: datetime
|
|
58
|
+
file_path: str
|
|
59
|
+
change_type: str # e.g., 'edit', 'write', 'snapshot', 'tool_call'
|
|
60
|
+
diff_summary: str
|
|
61
|
+
project_id: Optional[str] = None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Pricing engine package."""
|
|
2
|
+
|
|
3
|
+
from code_meter.pricing.anthropic import DEFAULT_ANTHROPIC_PRICING
|
|
4
|
+
from code_meter.pricing.engine import DEFAULT_PRICING, PricingEngine
|
|
5
|
+
from code_meter.pricing.google import DEFAULT_GOOGLE_PRICING
|
|
6
|
+
from code_meter.pricing.openai import DEFAULT_OPENAI_PRICING
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"PricingEngine",
|
|
10
|
+
"DEFAULT_PRICING",
|
|
11
|
+
"DEFAULT_ANTHROPIC_PRICING",
|
|
12
|
+
"DEFAULT_OPENAI_PRICING",
|
|
13
|
+
"DEFAULT_GOOGLE_PRICING",
|
|
14
|
+
]
|