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 +9 -0
- agent_cost/aggregate.py +201 -0
- agent_cost/cli.py +393 -0
- agent_cost/config.py +67 -0
- agent_cost/facts.py +93 -0
- agent_cost/rates.json +490 -0
- agent_cost/rates.py +280 -0
- agent_cost/readers/__init__.py +26 -0
- agent_cost/readers/claude.py +177 -0
- agent_cost/readers/codex.py +368 -0
- agent_cost/renderers.py +90 -0
- coding_agent_cost-0.1.0.dist-info/METADATA +247 -0
- coding_agent_cost-0.1.0.dist-info/RECORD +17 -0
- coding_agent_cost-0.1.0.dist-info/WHEEL +5 -0
- coding_agent_cost-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_cost-0.1.0.dist-info/licenses/LICENSE +21 -0
- coding_agent_cost-0.1.0.dist-info/top_level.txt +1 -0
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"
|
agent_cost/aggregate.py
ADDED
|
@@ -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
|
+
)
|