coding-agent-cost 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.
agent_cost/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """agent-cost: estimate AI coding agent token usage and cost from local logs.
2
+
3
+ Reads Claude Code and Codex CLI logs that already exist on disk and turns
4
+ them into per-event "facts" (billing events), then prices those facts
5
+ against a versioned rate catalog. Everything happens locally; there are no
6
+ network calls.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,201 @@
1
+ """Price facts and aggregate them into report rows.
2
+
3
+ Pricing and grouping are pure functions over a list of ``Fact`` and a
4
+ ``RateCatalog`` -- they know nothing about where the facts came from, so
5
+ they're testable independently of any reader.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from decimal import Decimal
13
+ from typing import Iterable, Optional, Tuple
14
+ from zoneinfo import ZoneInfo
15
+
16
+ from .facts import Fact
17
+ from .rates import RateCatalog
18
+
19
+ GROUP_DIMENSIONS = ("month", "agent", "model", "token-kind")
20
+ _STATUS_RANK = {"unpriced": 0, "lower_bound": 1, "priced": 2}
21
+
22
+
23
+ def price_fact(catalog: RateCatalog, fact: Fact) -> Tuple[Optional[Decimal], str, Optional[Decimal]]:
24
+ """Price one fact. Returns ``(estimated_cost_usd, pricing_status, credits)``.
25
+
26
+ ``cache_write_unknown`` (a cache write whose TTL couldn't be
27
+ determined) is priced at the 5-minute rate as a *lower bound* --
28
+ never at the more expensive 1-hour rate -- and flagged accordingly.
29
+ An unrecognized model or an unpriced token kind for an otherwise-known
30
+ model both return ``(None, "unpriced", None)`` rather than a guess.
31
+ """
32
+ resolved_key, period = catalog.rate_for(fact.model_key, fact.occurred_at_utc)
33
+ if resolved_key is None or period is None:
34
+ return None, "unpriced", None
35
+
36
+ rate_field = fact.token_kind
37
+ lower_bound = False
38
+ if fact.token_kind == "cache_write_unknown":
39
+ rate_field = "cache_write_5m"
40
+ lower_bound = True
41
+
42
+ rate = period.values.get(rate_field)
43
+ if rate is None:
44
+ return None, "unpriced", None
45
+
46
+ entry = catalog.models[resolved_key]
47
+ tokens = Decimal(fact.tokens)
48
+ multiplier = entry.fast_multiplier if fact.mode == "fast" else Decimal("1.0")
49
+ cost = (tokens / Decimal(1_000_000)) * rate * multiplier
50
+
51
+ credits = None
52
+ if entry.credits_per_mtok is not None:
53
+ credit_rate = entry.credits_per_mtok.get(rate_field)
54
+ if credit_rate is not None:
55
+ credits = (tokens / Decimal(1_000_000)) * credit_rate * multiplier
56
+
57
+ status = "lower_bound" if lower_bound else "priced"
58
+ return cost, status, credits
59
+
60
+
61
+ def filter_facts(
62
+ facts: Iterable[Fact],
63
+ *,
64
+ since_utc: Optional[datetime] = None,
65
+ until_utc: Optional[datetime] = None,
66
+ agents: Optional[set] = None,
67
+ ):
68
+ """Apply the ``[since, until)`` half-open window and an agent filter."""
69
+ for f in facts:
70
+ if since_utc is not None and f.occurred_at_utc < since_utc:
71
+ continue
72
+ if until_utc is not None and f.occurred_at_utc >= until_utc:
73
+ continue
74
+ if agents is not None and f.agent not in agents:
75
+ continue
76
+ yield f
77
+
78
+
79
+ @dataclass
80
+ class DataQuality:
81
+ malformed_events: int = 0
82
+ skipped_files: int = 0
83
+ negative_deltas: int = 0
84
+ unpriced_tokens: int = 0
85
+
86
+ def to_dict(self) -> dict:
87
+ return {
88
+ "malformed_events": self.malformed_events,
89
+ "skipped_files": self.skipped_files,
90
+ "negative_deltas": self.negative_deltas,
91
+ "unpriced_tokens": self.unpriced_tokens,
92
+ }
93
+
94
+
95
+ @dataclass
96
+ class Row:
97
+ month: Optional[str] = None
98
+ agent: Optional[str] = None
99
+ model: Optional[str] = None
100
+ token_kind: Optional[str] = None
101
+ tokens: int = 0
102
+ priced_tokens: int = 0
103
+ unpriced_tokens: int = 0
104
+ estimated_cost_usd: Decimal = field(default_factory=lambda: Decimal("0"))
105
+ credits: Decimal = field(default_factory=lambda: Decimal("0"))
106
+ pricing_status: str = "priced"
107
+
108
+ def to_dict(self) -> dict:
109
+ return {
110
+ "month": self.month,
111
+ "agent": self.agent,
112
+ "model": self.model,
113
+ "token_kind": self.token_kind,
114
+ "tokens": self.tokens,
115
+ "priced_tokens": self.priced_tokens,
116
+ "unpriced_tokens": self.unpriced_tokens,
117
+ "estimated_cost_usd": float(self.estimated_cost_usd),
118
+ "credits": float(self.credits),
119
+ "pricing_status": self.pricing_status,
120
+ }
121
+
122
+
123
+ def _month_key(dt: datetime, tz: ZoneInfo) -> str:
124
+ local = dt.astimezone(tz)
125
+ return f"{local.year:04d}-{local.month:02d}"
126
+
127
+
128
+ def build_rows(
129
+ facts: Iterable[Fact],
130
+ catalog: RateCatalog,
131
+ *,
132
+ group_by: Tuple[str, ...] = GROUP_DIMENSIONS,
133
+ timezone_name: str = "UTC",
134
+ ) -> Tuple[list, DataQuality]:
135
+ """Group priced facts into rows. Dimensions not in ``group_by`` are
136
+ left ``None`` on every row (per-fact filtering/pricing still happens
137
+ on the full, ungrouped dimension set).
138
+
139
+ A row's ``pricing_status`` is the worst status among its facts
140
+ (``unpriced`` beats ``lower_bound`` beats ``priced``), so a row is
141
+ never silently reported as fully priced when part of it wasn't.
142
+ """
143
+ tz = ZoneInfo(timezone_name)
144
+ buckets: dict = {}
145
+ unpriced_tokens_total = 0
146
+
147
+ for f in facts:
148
+ cost, status, credits = price_fact(catalog, f)
149
+ if status == "unpriced":
150
+ unpriced_tokens_total += f.tokens
151
+
152
+ month = _month_key(f.occurred_at_utc, tz) if "month" in group_by else None
153
+ agent = f.agent if "agent" in group_by else None
154
+ model = f.model_key if "model" in group_by else None
155
+ token_kind = f.token_kind if "token-kind" in group_by else None
156
+ key = (month, agent, model, token_kind)
157
+
158
+ row = buckets.get(key)
159
+ if row is None:
160
+ row = Row(month=month, agent=agent, model=model, token_kind=token_kind)
161
+ buckets[key] = row
162
+
163
+ row.tokens += f.tokens
164
+ if status == "unpriced":
165
+ row.unpriced_tokens += f.tokens
166
+ else:
167
+ row.priced_tokens += f.tokens
168
+ if cost is not None:
169
+ row.estimated_cost_usd += cost
170
+ if credits is not None:
171
+ row.credits += credits
172
+
173
+ if _STATUS_RANK[status] < _STATUS_RANK[row.pricing_status]:
174
+ row.pricing_status = status
175
+
176
+ rows = sorted(
177
+ buckets.values(),
178
+ key=lambda r: (r.month or "", r.agent or "", r.model or "", r.token_kind or ""),
179
+ )
180
+ return rows, DataQuality(unpriced_tokens=unpriced_tokens_total)
181
+
182
+
183
+ def rows_totals(rows: Iterable[Row]) -> dict:
184
+ """Sum a set of rows into a single scalar totals dict.
185
+
186
+ Sums the underlying Decimal cost/credits fields (not their float
187
+ conversions) so precision isn't lost across many rows before
188
+ converting to float once at the end, for callers (like ``measure``)
189
+ that need one aggregate number across rows that were grouped by
190
+ dimensions they don't care about.
191
+ """
192
+ rows = list(rows)
193
+ estimated_cost_usd = sum((r.estimated_cost_usd for r in rows), Decimal("0"))
194
+ credits = sum((r.credits for r in rows), Decimal("0"))
195
+ return {
196
+ "tokens": sum(r.tokens for r in rows),
197
+ "priced_tokens": sum(r.priced_tokens for r in rows),
198
+ "unpriced_tokens": sum(r.unpriced_tokens for r in rows),
199
+ "estimated_cost_usd": float(estimated_cost_usd),
200
+ "credits": float(credits),
201
+ }
agent_cost/cli.py ADDED
@@ -0,0 +1,393 @@
1
+ """Command-line entry point: ``agent-cost report / export / rates / doctor``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ from zoneinfo import ZoneInfo
12
+
13
+ from . import __version__
14
+ from .aggregate import DataQuality, build_rows, filter_facts, rows_totals
15
+ from .config import load_config
16
+ from .facts import SOURCE_QUALITY_VALUES
17
+ from .rates import RatesValidationError, load_rates
18
+ from .readers import claude as claude_reader
19
+ from .readers import codex as codex_reader
20
+ from .renderers import render_csv, render_json, render_table
21
+
22
+ #: agent-cost measure's output contract version. Bump on a breaking change
23
+ #: to the JSON shape (removed/renamed field, changed field meaning); adding
24
+ #: a new field is not breaking. Consumers (e.g. lane's TelemetryAdapter)
25
+ #: should check this before trusting the shape of the payload.
26
+ MEASURE_PROTOCOL_VERSION = "measure/v1"
27
+
28
+
29
+ def _parse_window_bound(value: Optional[str], tz: ZoneInfo) -> Optional[datetime]:
30
+ if not value:
31
+ return None
32
+ # Python 3.9-3.10's datetime.fromisoformat() rejects the "Z" (Zulu/UTC)
33
+ # suffix that ISO 8601 allows and that, e.g., JavaScript's
34
+ # Date.toISOString() always emits -- normalize it to the equivalent
35
+ # "+00:00" offset fromisoformat does accept, before parsing.
36
+ text = value[:-1] + "+00:00" if value.endswith("Z") else value
37
+ try:
38
+ dt = datetime.fromisoformat(text)
39
+ except ValueError as exc:
40
+ raise SystemExit(f"[error] invalid date/time: {value!r} ({exc})")
41
+ if dt.tzinfo is not None:
42
+ return dt.astimezone(timezone.utc)
43
+ # Date-only (or naive datetime) input is interpreted in --timezone.
44
+ return dt.replace(tzinfo=tz).astimezone(timezone.utc)
45
+
46
+
47
+ def _collect_facts(config, *, agents: set, exclude_archived: bool):
48
+ facts: list = []
49
+ dq = DataQuality()
50
+
51
+ if "claude" in agents:
52
+ result = claude_reader.read_claude_facts(config.claude_projects_dir)
53
+ facts.extend(result.facts)
54
+ dq.malformed_events += result.malformed_events
55
+ dq.skipped_files += result.skipped_files
56
+
57
+ if "codex" in agents and config.codex_db_path.exists():
58
+ result = codex_reader.read_codex_facts(
59
+ config.codex_db_path,
60
+ config.codex_home,
61
+ include_archived=not exclude_archived,
62
+ )
63
+ facts.extend(result.facts)
64
+ dq.malformed_events += result.malformed_events
65
+ dq.skipped_files += result.skipped_files
66
+ dq.negative_deltas += result.negative_deltas
67
+
68
+ return facts, dq
69
+
70
+
71
+ def cmd_report(args) -> int:
72
+ config = load_config()
73
+ tz = ZoneInfo(args.timezone)
74
+ since = _parse_window_bound(args.since, tz)
75
+ until = _parse_window_bound(args.until, tz)
76
+ agents = set(args.agent.split(",")) if args.agent else {"claude", "codex"}
77
+ group_by = tuple(args.group_by.split(",")) if args.group_by else (
78
+ "month",
79
+ "agent",
80
+ "model",
81
+ "token-kind",
82
+ )
83
+
84
+ try:
85
+ catalog = load_rates(Path(args.rates) if args.rates else None)
86
+ except RatesValidationError as exc:
87
+ print(f"[error] rates catalog invalid: {exc}", file=sys.stderr)
88
+ return 2
89
+
90
+ facts, dq = _collect_facts(config, agents=agents, exclude_archived=args.exclude_archived)
91
+ facts = list(filter_facts(facts, since_utc=since, until_utc=until, agents=agents))
92
+ rows, agg_dq = build_rows(facts, catalog, group_by=group_by, timezone_name=args.timezone)
93
+ dq.unpriced_tokens = agg_dq.unpriced_tokens
94
+
95
+ payload = {
96
+ "schema_version": "1",
97
+ "generated_at": datetime.now(timezone.utc).isoformat(),
98
+ "window": {
99
+ "since": since.isoformat() if since else None,
100
+ "until": until.isoformat() if until else None,
101
+ },
102
+ "timezone": args.timezone,
103
+ "rates": {"catalog_version": catalog.catalog_version, "sha256": catalog.sha256},
104
+ "group_by": list(group_by),
105
+ "data_quality": dq.to_dict(),
106
+ "rows": [r.to_dict() for r in rows],
107
+ }
108
+
109
+ renderer = {"table": render_table, "csv": render_csv, "json": render_json}[args.format]
110
+ print(renderer(payload))
111
+ return 0
112
+
113
+
114
+ def cmd_export(args) -> int:
115
+ config = load_config()
116
+ tz = ZoneInfo(args.timezone)
117
+ since = _parse_window_bound(args.since, tz)
118
+ until = _parse_window_bound(args.until, tz)
119
+ agents = set(args.agent.split(",")) if args.agent else {"claude", "codex"}
120
+
121
+ facts, _dq = _collect_facts(config, agents=agents, exclude_archived=False)
122
+ facts = list(filter_facts(facts, since_utc=since, until_utc=until, agents=agents))
123
+
124
+ out = open(args.out, "w") if args.out else sys.stdout
125
+ try:
126
+ for f in facts:
127
+ record = {
128
+ "occurred_at_utc": f.occurred_at_utc.isoformat(),
129
+ "agent": f.agent,
130
+ "session_id": f.session_id,
131
+ "model_raw": f.model_raw,
132
+ "model_key": f.model_key,
133
+ "token_kind": f.token_kind,
134
+ "tokens": f.tokens,
135
+ "mode": f.mode,
136
+ "source_quality": f.source_quality,
137
+ }
138
+ out.write(json.dumps(record, ensure_ascii=False) + "\n")
139
+ finally:
140
+ if args.out:
141
+ out.close()
142
+ return 0
143
+
144
+
145
+ def cmd_measure(args) -> int:
146
+ """Machine-readable per-session usage/cost digest for other tools.
147
+
148
+ Unlike ``report`` (a human-facing table by default), ``measure`` is a
149
+ stable JSON contract meant to be parsed by another process (e.g.
150
+ lane's TelemetryAdapter calling this as a subprocess): fixed top-level
151
+ keys, a ``protocol_version`` a caller can check, and exit codes a
152
+ caller can branch on (0 = success, including the case where none of
153
+ the requested session ids matched anything; 2 = bad input, nothing
154
+ was measured).
155
+ """
156
+ session_ids = list(dict.fromkeys(args.session_id or []))
157
+ if not session_ids:
158
+ print("[error] measure requires at least one --session-id", file=sys.stderr)
159
+ return 2
160
+
161
+ try:
162
+ tz = ZoneInfo(args.timezone)
163
+ except Exception as exc: # zoneinfo raises a range of errors for a bad key
164
+ print(f"[error] invalid --timezone: {args.timezone!r} ({exc})", file=sys.stderr)
165
+ return 2
166
+
167
+ try:
168
+ since = _parse_window_bound(args.since, tz)
169
+ until = _parse_window_bound(args.until, tz)
170
+ except SystemExit as exc:
171
+ print(str(exc), file=sys.stderr)
172
+ return 2
173
+
174
+ agents = set(args.agent.split(",")) if args.agent else {"claude", "codex"}
175
+
176
+ try:
177
+ catalog = load_rates(Path(args.rates) if args.rates else None)
178
+ except RatesValidationError as exc:
179
+ print(f"[error] rates catalog invalid: {exc}", file=sys.stderr)
180
+ return 2
181
+
182
+ config = load_config()
183
+ facts, dq = _collect_facts(config, agents=agents, exclude_archived=False)
184
+ facts = list(filter_facts(facts, since_utc=since, until_utc=until, agents=agents))
185
+
186
+ # measure is a per-session query, not a time-bucketed report: group by
187
+ # agent/model/token-kind only, never by month.
188
+ group_by = ("agent", "model", "token-kind")
189
+
190
+ requested = set(session_ids)
191
+ combined_facts = [f for f in facts if f.session_id in requested]
192
+
193
+ quality_counts = {v: 0 for v in SOURCE_QUALITY_VALUES}
194
+ for f in combined_facts:
195
+ quality_counts[f.source_quality] = quality_counts.get(f.source_quality, 0) + 1
196
+
197
+ sessions_payload = {}
198
+ for sid in session_ids:
199
+ session_facts = [f for f in combined_facts if f.session_id == sid]
200
+ rows, _ = build_rows(session_facts, catalog, group_by=group_by, timezone_name=args.timezone)
201
+ sessions_payload[sid] = {
202
+ "matched": len(session_facts) > 0,
203
+ "rows": [r.to_dict() for r in rows],
204
+ "totals": rows_totals(rows),
205
+ }
206
+
207
+ total_rows, total_dq = build_rows(combined_facts, catalog, group_by=group_by, timezone_name=args.timezone)
208
+
209
+ payload = {
210
+ "protocol_version": MEASURE_PROTOCOL_VERSION,
211
+ "generated_at": datetime.now(timezone.utc).isoformat(),
212
+ "window": {
213
+ "since": since.isoformat() if since else None,
214
+ "until": until.isoformat() if until else None,
215
+ },
216
+ "timezone": args.timezone,
217
+ "agent": sorted(agents),
218
+ "rates": {"catalog_version": catalog.catalog_version, "sha256": catalog.sha256},
219
+ "session_ids": session_ids,
220
+ "sessions": sessions_payload,
221
+ "total": {
222
+ "rows": [r.to_dict() for r in total_rows],
223
+ "totals": rows_totals(total_rows),
224
+ },
225
+ "data_quality": {
226
+ "malformed_events": dq.malformed_events,
227
+ "skipped_files": dq.skipped_files,
228
+ "negative_deltas": dq.negative_deltas,
229
+ "unpriced_tokens": total_dq.unpriced_tokens,
230
+ "source_quality": quality_counts,
231
+ },
232
+ }
233
+ print(render_json(payload))
234
+ return 0
235
+
236
+
237
+ def cmd_rates_show(args) -> int:
238
+ try:
239
+ catalog = load_rates(Path(args.rates) if args.rates else None)
240
+ except RatesValidationError as exc:
241
+ print(f"[error] {exc}", file=sys.stderr)
242
+ return 2
243
+
244
+ if args.model:
245
+ resolved = catalog.resolve_model_key(args.model)
246
+ if not resolved:
247
+ print(f"[unpriced] no rate entry for {args.model!r}")
248
+ return 0
249
+ entry = catalog.models[resolved]
250
+ print(f"model_key: {entry.model_key} (queried: {args.model})")
251
+ print(f"aliases: {list(entry.aliases)}")
252
+ print(f"fast_multiplier: {entry.fast_multiplier}")
253
+ for period in entry.rates:
254
+ until = period.effective_until.isoformat() if period.effective_until else "(open)"
255
+ print(f" [{period.rate_id}] {period.effective_from.isoformat()} .. {until}")
256
+ for field_name, value in period.values.items():
257
+ print(f" {field_name}: {value if value is not None else '(unpriced)'}")
258
+ return 0
259
+
260
+ print(f"catalog_version: {catalog.catalog_version}")
261
+ print(f"currency: {catalog.currency} unit: {catalog.unit} usd_per_credit: {catalog.usd_per_credit}")
262
+ print(f"models ({len(catalog.models)}):")
263
+ for key in sorted(catalog.models):
264
+ print(f" - {key}")
265
+ return 0
266
+
267
+
268
+ def cmd_rates_validate(args) -> int:
269
+ path = Path(args.path) if args.path else None
270
+ try:
271
+ catalog = load_rates(path)
272
+ except RatesValidationError as exc:
273
+ print(f"[invalid] {exc}", file=sys.stderr)
274
+ return 1
275
+ print(
276
+ f"[ok] {path or '(packaged default)'}: catalog_version={catalog.catalog_version}, "
277
+ f"{len(catalog.models)} models, sha256={catalog.sha256}"
278
+ )
279
+ return 0
280
+
281
+
282
+ def cmd_doctor(_args) -> int:
283
+ config = load_config()
284
+ ok = True
285
+
286
+ print("agent-cost doctor")
287
+ print(f" version: {__version__}")
288
+
289
+ claude_dir = config.claude_projects_dir
290
+ if claude_dir.exists():
291
+ count = sum(1 for _ in claude_dir.glob("*/*.jsonl"))
292
+ print(f" [ok] Claude projects dir: {claude_dir} ({count} session files)")
293
+ else:
294
+ print(f" [warn] Claude projects dir not found: {claude_dir}")
295
+
296
+ codex_db = config.codex_db_path
297
+ if codex_db.exists():
298
+ print(f" [ok] Codex state DB found: {codex_db}")
299
+ try:
300
+ snapshot = codex_reader.snapshot_db(codex_db)
301
+ try:
302
+ threads = codex_reader.fetch_threads(snapshot)
303
+ print(f" [ok] Codex threads readable via sqlite3 ({len(threads)} threads)")
304
+ finally:
305
+ snapshot.unlink(missing_ok=True)
306
+ except Exception as exc: # surfaced as a doctor finding, not a crash
307
+ ok = False
308
+ print(f" [error] Codex state DB read failed: {exc}")
309
+ else:
310
+ print(f" [warn] Codex state DB not found: {codex_db}")
311
+
312
+ try:
313
+ catalog = load_rates()
314
+ print(f" [ok] rates.json valid: catalog_version={catalog.catalog_version}, {len(catalog.models)} models")
315
+ except RatesValidationError as exc:
316
+ ok = False
317
+ print(f" [error] rates.json invalid: {exc}")
318
+
319
+ return 0 if ok else 1
320
+
321
+
322
+ def build_parser() -> argparse.ArgumentParser:
323
+ parser = argparse.ArgumentParser(
324
+ prog="agent-cost",
325
+ description=(
326
+ "Estimate AI coding agent token usage and cost from local logs "
327
+ "(Claude Code, Codex CLI). Fully offline; makes no network calls."
328
+ ),
329
+ )
330
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
331
+ sub = parser.add_subparsers(dest="command", required=True)
332
+
333
+ p_report = sub.add_parser("report", help="Aggregate usage and estimated cost")
334
+ p_report.add_argument("--since", help="ISO date/datetime, inclusive")
335
+ p_report.add_argument("--until", help="ISO date/datetime, exclusive")
336
+ p_report.add_argument("--timezone", default="UTC", help="IANA zone for date-only input and month grouping")
337
+ p_report.add_argument("--agent", help="comma-separated: claude,codex")
338
+ p_report.add_argument("--group-by", help="comma-separated: month,agent,model,token-kind")
339
+ p_report.add_argument("--format", choices=["table", "csv", "json"], default="table")
340
+ p_report.add_argument("--rates", help="path to a rates.json that fully replaces the packaged catalog")
341
+ p_report.add_argument("--exclude-archived", action="store_true", help="exclude archived Codex threads")
342
+ p_report.set_defaults(func=cmd_report)
343
+
344
+ p_export = sub.add_parser("export", help="Export canonical facts as JSONL")
345
+ p_export.add_argument("--agent", help="comma-separated: claude,codex")
346
+ p_export.add_argument("--since")
347
+ p_export.add_argument("--until")
348
+ p_export.add_argument("--timezone", default="UTC")
349
+ p_export.add_argument("--out", help="output path (default: stdout)")
350
+ p_export.set_defaults(func=cmd_export)
351
+
352
+ p_measure = sub.add_parser(
353
+ "measure",
354
+ help=f"Machine-readable per-session usage/cost digest ({MEASURE_PROTOCOL_VERSION})",
355
+ )
356
+ p_measure.add_argument(
357
+ "--session-id",
358
+ action="append",
359
+ dest="session_id",
360
+ help="repeatable; at least one required",
361
+ )
362
+ p_measure.add_argument("--since", help="ISO date/datetime, inclusive")
363
+ p_measure.add_argument("--until", help="ISO date/datetime, exclusive")
364
+ p_measure.add_argument("--timezone", default="UTC", help="IANA zone for date-only input")
365
+ p_measure.add_argument("--agent", help="comma-separated: claude,codex")
366
+ p_measure.add_argument("--rates", help="path to a rates.json that fully replaces the packaged catalog")
367
+ p_measure.add_argument("--format", choices=["json"], default="json")
368
+ p_measure.set_defaults(func=cmd_measure)
369
+
370
+ p_rates = sub.add_parser("rates", help="Inspect or validate a rates catalog")
371
+ rates_sub = p_rates.add_subparsers(dest="rates_command", required=True)
372
+ p_rates_show = rates_sub.add_parser("show")
373
+ p_rates_show.add_argument("--model")
374
+ p_rates_show.add_argument("--rates")
375
+ p_rates_show.set_defaults(func=cmd_rates_show)
376
+ p_rates_validate = rates_sub.add_parser("validate")
377
+ p_rates_validate.add_argument("path", nargs="?")
378
+ p_rates_validate.set_defaults(func=cmd_rates_validate)
379
+
380
+ p_doctor = sub.add_parser("doctor", help="Check environment and rates catalog health")
381
+ p_doctor.set_defaults(func=cmd_doctor)
382
+
383
+ return parser
384
+
385
+
386
+ def main(argv=None) -> int:
387
+ parser = build_parser()
388
+ args = parser.parse_args(argv)
389
+ return args.func(args)
390
+
391
+
392
+ if __name__ == "__main__":
393
+ sys.exit(main())
agent_cost/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """Runtime configuration: where to find each tool's local logs.
2
+
3
+ Resolution order (highest priority first): environment variables, then
4
+ ``~/.config/agent-cost/config.json`` (or the path in ``AGENT_COST_CONFIG``),
5
+ then built-in defaults.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "agent-cost" / "config.json"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ claude_home: Path
22
+ codex_home: Path
23
+ codex_db_filename: str = "state_5.sqlite"
24
+
25
+ @property
26
+ def claude_projects_dir(self) -> Path:
27
+ return self.claude_home / "projects"
28
+
29
+ @property
30
+ def codex_db_path(self) -> Path:
31
+ return self.codex_home / self.codex_db_filename
32
+
33
+
34
+ def _load_config_file(explicit_path: Optional[Path]) -> dict:
35
+ config_path = explicit_path or Path(
36
+ os.environ.get("AGENT_COST_CONFIG", str(DEFAULT_CONFIG_PATH))
37
+ )
38
+ if not config_path.exists():
39
+ return {}
40
+ try:
41
+ with config_path.open() as fh:
42
+ data = json.load(fh)
43
+ except (OSError, json.JSONDecodeError):
44
+ return {}
45
+ return data if isinstance(data, dict) else {}
46
+
47
+
48
+ def load_config(config_path: Optional[Path] = None) -> Config:
49
+ file_data = _load_config_file(config_path)
50
+
51
+ claude_home = Path(
52
+ os.environ.get("CLAUDE_HOME")
53
+ or file_data.get("claude_home")
54
+ or os.path.expanduser("~/.claude")
55
+ )
56
+ codex_home = Path(
57
+ os.environ.get("CODEX_HOME")
58
+ or file_data.get("codex_home")
59
+ or os.path.expanduser("~/.codex")
60
+ )
61
+ codex_db_filename = str(file_data.get("codex_db_filename") or "state_5.sqlite")
62
+
63
+ return Config(
64
+ claude_home=claude_home,
65
+ codex_home=codex_home,
66
+ codex_db_filename=codex_db_filename,
67
+ )