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/rates.py ADDED
@@ -0,0 +1,280 @@
1
+ """Load, validate, and query the historical rate catalog (rates.json).
2
+
3
+ The catalog is a plain JSON document describing, per model, one or more
4
+ time-bounded rate periods (so a price change shows up as a new period
5
+ rather than overwriting history). All rate values are decimal strings in
6
+ the JSON and are parsed with `decimal.Decimal` throughout -- float is
7
+ never used for money.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from decimal import Decimal, InvalidOperation
17
+ from importlib import resources
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ RATE_FIELDS = (
22
+ "input_nocache",
23
+ "cache_read",
24
+ "cache_write_5m",
25
+ "cache_write_1h",
26
+ "output",
27
+ )
28
+
29
+ # The only schema_version/currency/unit values this version of agent-cost
30
+ # knows how to interpret. A catalog claiming anything else is rejected
31
+ # outright rather than silently treated as USD-per-mtok -- a catalog in a
32
+ # different currency or unit priced as if it were USD-per-mtok would be
33
+ # wrong by a fixed, silent factor.
34
+ SUPPORTED_SCHEMA_VERSIONS = ("1",)
35
+ SUPPORTED_CURRENCIES = ("USD",)
36
+ SUPPORTED_UNITS = ("per_mtok",)
37
+
38
+
39
+ class RatesValidationError(ValueError):
40
+ """The rates catalog failed structural or business-rule validation."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RatePeriod:
45
+ rate_id: str
46
+ effective_from: datetime
47
+ effective_until: Optional[datetime]
48
+ values: dict
49
+
50
+ def covers(self, moment: datetime) -> bool:
51
+ if moment < self.effective_from:
52
+ return False
53
+ if self.effective_until is not None and moment >= self.effective_until:
54
+ return False
55
+ return True
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ModelEntry:
60
+ model_key: str
61
+ aliases: tuple
62
+ fast_multiplier: Decimal
63
+ rates: tuple
64
+ credits_per_mtok: Optional[dict] = None
65
+
66
+
67
+ @dataclass
68
+ class RateCatalog:
69
+ schema_version: str
70
+ catalog_version: str
71
+ currency: str
72
+ unit: str
73
+ usd_per_credit: Decimal
74
+ sources: list
75
+ models: dict = field(default_factory=dict)
76
+ sha256: str = ""
77
+ _alias_index: dict = field(default_factory=dict)
78
+
79
+ def resolve_model_key(self, model_key: str) -> Optional[str]:
80
+ """Map a fact's ``model_key`` to a canonical catalog ``model_key``.
81
+
82
+ Tries an exact match first, then the catalog's alias table (this
83
+ is separate from -- and applied after -- the generic suffix
84
+ stripping in ``facts.normalize_model_key``).
85
+ """
86
+ if model_key in self.models:
87
+ return model_key
88
+ return self._alias_index.get(model_key)
89
+
90
+ def rate_for(self, model_key: str, occurred_at_utc: datetime):
91
+ """Return ``(resolved_model_key, RatePeriod)``, either of which may
92
+ be ``None`` when the model or the specific period is not priced."""
93
+ resolved = self.resolve_model_key(model_key)
94
+ if resolved is None:
95
+ return None, None
96
+ entry = self.models[resolved]
97
+ for period in entry.rates:
98
+ if period.covers(occurred_at_utc):
99
+ return resolved, period
100
+ return resolved, None
101
+
102
+
103
+ def _parse_decimal(value, *, field_name: str) -> Optional[Decimal]:
104
+ if value is None:
105
+ return None
106
+ try:
107
+ d = Decimal(str(value))
108
+ except InvalidOperation as exc:
109
+ raise RatesValidationError(f"invalid decimal for {field_name}: {value!r}") from exc
110
+ if d < 0:
111
+ raise RatesValidationError(f"{field_name} must be >= 0, got {d}")
112
+ return d
113
+
114
+
115
+ def _parse_datetime(value, *, field_name: str) -> datetime:
116
+ try:
117
+ text = value
118
+ if isinstance(text, str) and text.endswith("Z"):
119
+ text = text[:-1] + "+00:00"
120
+ dt = datetime.fromisoformat(text)
121
+ except (ValueError, TypeError) as exc:
122
+ raise RatesValidationError(f"invalid datetime for {field_name}: {value!r}") from exc
123
+ if dt.tzinfo is None:
124
+ dt = dt.replace(tzinfo=timezone.utc)
125
+ return dt.astimezone(timezone.utc)
126
+
127
+
128
+ def _build_model_entry(raw: dict) -> ModelEntry:
129
+ model_key = raw.get("model_key")
130
+ if not model_key or not isinstance(model_key, str):
131
+ raise RatesValidationError(f"model entry missing model_key: {raw!r}")
132
+
133
+ aliases = tuple(raw.get("aliases") or [])
134
+
135
+ fast_multiplier = _parse_decimal(
136
+ raw.get("fast_multiplier", "1.0"), field_name=f"{model_key}.fast_multiplier"
137
+ )
138
+ if fast_multiplier is None:
139
+ fast_multiplier = Decimal("1.0")
140
+
141
+ credits_raw = raw.get("credits_per_mtok")
142
+ credits_per_mtok = None
143
+ if credits_raw is not None:
144
+ credits_per_mtok = {
145
+ f: _parse_decimal(credits_raw.get(f), field_name=f"{model_key}.credits_per_mtok.{f}")
146
+ for f in RATE_FIELDS
147
+ }
148
+
149
+ periods = []
150
+ for rate_raw in raw.get("rates") or []:
151
+ rate_id = rate_raw.get("rate_id")
152
+ if not rate_id:
153
+ raise RatesValidationError(f"{model_key}: rate entry missing rate_id")
154
+ if "effective_from" not in rate_raw:
155
+ raise RatesValidationError(f"{model_key}.{rate_id}: missing effective_from")
156
+ effective_from = _parse_datetime(
157
+ rate_raw["effective_from"], field_name=f"{model_key}.{rate_id}.effective_from"
158
+ )
159
+ effective_until = (
160
+ _parse_datetime(rate_raw["effective_until"], field_name=f"{model_key}.{rate_id}.effective_until")
161
+ if rate_raw.get("effective_until")
162
+ else None
163
+ )
164
+ if effective_until is not None and effective_until <= effective_from:
165
+ raise RatesValidationError(
166
+ f"{model_key}.{rate_id}: effective_until must be after effective_from"
167
+ )
168
+ values = {
169
+ f: _parse_decimal(rate_raw.get(f), field_name=f"{model_key}.{rate_id}.{f}")
170
+ for f in RATE_FIELDS
171
+ }
172
+ periods.append(
173
+ RatePeriod(
174
+ rate_id=rate_id,
175
+ effective_from=effective_from,
176
+ effective_until=effective_until,
177
+ values=values,
178
+ )
179
+ )
180
+
181
+ periods.sort(key=lambda p: p.effective_from)
182
+ open_end = datetime.max.replace(tzinfo=timezone.utc)
183
+ for a, b in zip(periods, periods[1:]):
184
+ a_end = a.effective_until or open_end
185
+ if a_end > b.effective_from:
186
+ raise RatesValidationError(
187
+ f"{model_key}: overlapping rate periods {a.rate_id!r} and {b.rate_id!r}"
188
+ )
189
+
190
+ return ModelEntry(
191
+ model_key=model_key,
192
+ aliases=aliases,
193
+ fast_multiplier=fast_multiplier,
194
+ rates=tuple(periods),
195
+ credits_per_mtok=credits_per_mtok,
196
+ )
197
+
198
+
199
+ def _validate_and_build(data: dict) -> RateCatalog:
200
+ if not isinstance(data, dict):
201
+ raise RatesValidationError("rates catalog root must be an object")
202
+
203
+ catalog_version = data.get("catalog_version")
204
+ if not catalog_version:
205
+ raise RatesValidationError("missing catalog_version")
206
+
207
+ schema_version = data.get("schema_version")
208
+ if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
209
+ raise RatesValidationError(
210
+ f"unsupported schema_version: {schema_version!r} (supported: {SUPPORTED_SCHEMA_VERSIONS})"
211
+ )
212
+
213
+ currency = data.get("currency")
214
+ if currency not in SUPPORTED_CURRENCIES:
215
+ raise RatesValidationError(
216
+ f"unsupported currency: {currency!r} (supported: {SUPPORTED_CURRENCIES})"
217
+ )
218
+
219
+ unit = data.get("unit")
220
+ if unit not in SUPPORTED_UNITS:
221
+ raise RatesValidationError(f"unsupported unit: {unit!r} (supported: {SUPPORTED_UNITS})")
222
+
223
+ usd_per_credit = _parse_decimal(data.get("usd_per_credit"), field_name="usd_per_credit")
224
+ if usd_per_credit is None:
225
+ usd_per_credit = Decimal("0")
226
+ sources = data.get("sources") or []
227
+
228
+ models: dict = {}
229
+ for raw in data.get("models") or []:
230
+ entry = _build_model_entry(raw)
231
+ if entry.model_key in models:
232
+ raise RatesValidationError(f"duplicate model_key: {entry.model_key}")
233
+ models[entry.model_key] = entry
234
+
235
+ alias_index: dict = {}
236
+ for entry in models.values():
237
+ for alias in entry.aliases:
238
+ if alias == entry.model_key:
239
+ raise RatesValidationError(f"{entry.model_key}: alias cannot equal its own model_key")
240
+ if alias in models:
241
+ # Aliases live in a separate namespace from model_keys by
242
+ # construction; anything else would let alias resolution
243
+ # loop back on itself (an alias cycle).
244
+ raise RatesValidationError(
245
+ f"alias {alias!r} on {entry.model_key} collides with an existing model_key"
246
+ )
247
+ if alias in alias_index:
248
+ raise RatesValidationError(f"duplicate alias: {alias!r}")
249
+ alias_index[alias] = entry.model_key
250
+
251
+ return RateCatalog(
252
+ schema_version=schema_version,
253
+ catalog_version=catalog_version,
254
+ currency=currency,
255
+ unit=unit,
256
+ usd_per_credit=usd_per_credit,
257
+ sources=sources,
258
+ models=models,
259
+ _alias_index=alias_index,
260
+ )
261
+
262
+
263
+ def load_rates(path: Optional[Path] = None) -> RateCatalog:
264
+ """Load and validate a rate catalog.
265
+
266
+ ``path=None`` loads the catalog packaged with agent-cost
267
+ (``agent_cost/rates.json``). A caller-supplied path fully replaces
268
+ the packaged catalog -- it is never merged with it.
269
+ """
270
+ if path is not None:
271
+ raw_bytes = Path(path).read_bytes()
272
+ else:
273
+ raw_bytes = resources.files("agent_cost").joinpath("rates.json").read_bytes()
274
+ try:
275
+ data = json.loads(raw_bytes)
276
+ except json.JSONDecodeError as exc:
277
+ raise RatesValidationError(f"invalid JSON: {exc}") from exc
278
+ catalog = _validate_and_build(data)
279
+ catalog.sha256 = hashlib.sha256(raw_bytes).hexdigest()
280
+ return catalog
@@ -0,0 +1,26 @@
1
+ """Log readers: turn a tool's local files into a stream of canonical facts.
2
+
3
+ Each reader module exposes a ``ReadResult`` (the facts plus counters for
4
+ anything that looked broken along the way) and a top-level ``read_*``
5
+ function. Readers never make network calls and never write to the source
6
+ logs they read.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import List
11
+
12
+ from ..facts import Fact
13
+
14
+
15
+ @dataclass
16
+ class ReadResult:
17
+ facts: List[Fact] = field(default_factory=list)
18
+ malformed_events: int = 0
19
+ skipped_files: int = 0
20
+ negative_deltas: int = 0
21
+ # Codex-only diagnostic: number of threads where the state DB's
22
+ # `tokens_used` column diverges from the sum of that thread's derived
23
+ # facts by more than 1% or 100 tokens. Not part of the report JSON's
24
+ # `data_quality` (which is reader-agnostic); surfaced for `doctor` /
25
+ # tests.
26
+ tokens_used_diffs: int = 0
@@ -0,0 +1,177 @@
1
+ """Claude Code reader: ``~/.claude/projects/**/*.jsonl`` -> facts.
2
+
3
+ Each session file is a JSONL transcript. Every ``assistant`` event that
4
+ carries a ``message.usage`` block is one billing event and becomes its own
5
+ set of facts, attributed to the model recorded on *that* event
6
+ (``message.model``) -- never to a session-wide majority model, since a
7
+ single session can span multiple models.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+ from typing import Iterator, Optional, Tuple
16
+
17
+ from . import ReadResult
18
+ from ..facts import Fact, normalize_model_key
19
+
20
+
21
+ def _parse_timestamp(ts: Optional[str]) -> Optional[datetime]:
22
+ if not ts:
23
+ return None
24
+ try:
25
+ text = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
26
+ dt = datetime.fromisoformat(text)
27
+ except ValueError:
28
+ return None
29
+ if dt.tzinfo is None:
30
+ dt = dt.replace(tzinfo=timezone.utc)
31
+ return dt.astimezone(timezone.utc)
32
+
33
+
34
+ def _detect_mode(usage: dict) -> str:
35
+ speed = usage.get("speed")
36
+ if not speed:
37
+ return "unknown"
38
+ return "fast" if "fast" in str(speed).strip().lower() else "normal"
39
+
40
+
41
+ def parse_session_facts(jsonl_path: Path) -> Tuple[list, int]:
42
+ """Stream one session file into facts. Returns ``(facts, malformed_events)``.
43
+
44
+ Raises ``OSError`` if the file cannot be read at all (the caller
45
+ counts that as a skipped file, distinct from a malformed *line*).
46
+ """
47
+ facts: list = []
48
+ malformed = 0
49
+ session_id: Optional[str] = None
50
+
51
+ with jsonl_path.open() as fh:
52
+ for line in fh:
53
+ line = line.strip()
54
+ if not line:
55
+ continue
56
+ try:
57
+ event = json.loads(line)
58
+ except json.JSONDecodeError:
59
+ malformed += 1
60
+ continue
61
+
62
+ if not session_id and event.get("sessionId"):
63
+ session_id = event.get("sessionId")
64
+
65
+ if event.get("type") != "assistant":
66
+ continue
67
+ message = event.get("message") or {}
68
+ usage = message.get("usage")
69
+ if not isinstance(usage, dict):
70
+ continue
71
+
72
+ occurred_at = _parse_timestamp(event.get("timestamp"))
73
+ if occurred_at is None:
74
+ malformed += 1
75
+ continue
76
+
77
+ model_raw = message.get("model") or "(unknown)"
78
+ model_key = normalize_model_key(model_raw)
79
+ mode = _detect_mode(usage)
80
+ sid = event.get("sessionId") or session_id
81
+
82
+ def _emit(kind: str, tokens: int) -> None:
83
+ if tokens > 0:
84
+ facts.append(
85
+ Fact(
86
+ occurred_at_utc=occurred_at,
87
+ agent="claude",
88
+ session_id=sid,
89
+ model_raw=model_raw,
90
+ model_key=model_key,
91
+ token_kind=kind,
92
+ tokens=tokens,
93
+ mode=mode,
94
+ )
95
+ )
96
+
97
+ try:
98
+ _emit("input_nocache", int(usage.get("input_tokens") or 0))
99
+ _emit("cache_read", int(usage.get("cache_read_input_tokens") or 0))
100
+
101
+ cache_creation = usage.get("cache_creation")
102
+ cache_creation_total = int(usage.get("cache_creation_input_tokens") or 0)
103
+ if isinstance(cache_creation, dict):
104
+ ephemeral_5m = int(cache_creation.get("ephemeral_5m_input_tokens") or 0)
105
+ ephemeral_1h = int(cache_creation.get("ephemeral_1h_input_tokens") or 0)
106
+ _emit("cache_write_5m", ephemeral_5m)
107
+ _emit("cache_write_1h", ephemeral_1h)
108
+ leftover = cache_creation_total - (ephemeral_5m + ephemeral_1h)
109
+ if leftover > 0:
110
+ _emit("cache_write_unknown", leftover)
111
+ elif cache_creation_total:
112
+ _emit("cache_write_unknown", cache_creation_total)
113
+
114
+ _emit("output", int(usage.get("output_tokens") or 0))
115
+ except (TypeError, ValueError):
116
+ malformed += 1
117
+ continue
118
+
119
+ return facts, malformed
120
+
121
+
122
+ def iter_project_files(claude_projects_dir: Path) -> Iterator[Path]:
123
+ if not claude_projects_dir.exists():
124
+ return
125
+ for project_dir in sorted(claude_projects_dir.iterdir()):
126
+ if not project_dir.is_dir():
127
+ continue
128
+ yield from sorted(project_dir.glob("*.jsonl"))
129
+
130
+
131
+ def read_claude_facts(
132
+ claude_projects_dir: Path,
133
+ *,
134
+ since_utc: Optional[datetime] = None,
135
+ until_utc: Optional[datetime] = None,
136
+ ) -> ReadResult:
137
+ """Read every session file under ``claude_projects_dir`` into facts.
138
+
139
+ A file's mtime is used only as a coarse "can we skip reading this
140
+ file entirely" optimization on the *since* side: a file untouched
141
+ since well before the window cannot contain any event inside it
142
+ (every event's timestamp is <= the file's mtime), so skipping it is
143
+ safe. There is no equivalent skip on the *until* side -- a file
144
+ modified after the window can easily still contain earlier events
145
+ that fall inside it, so skipping on a late mtime would silently drop
146
+ real in-window data. The exact since/until boundary is always
147
+ re-checked per fact below regardless.
148
+ """
149
+ all_facts: list = []
150
+ malformed_total = 0
151
+ skipped_files = 0
152
+
153
+ for jsonl_path in iter_project_files(claude_projects_dir):
154
+ if since_utc is not None:
155
+ try:
156
+ mtime = datetime.fromtimestamp(jsonl_path.stat().st_mtime, tz=timezone.utc)
157
+ except OSError:
158
+ skipped_files += 1
159
+ continue
160
+ if mtime < since_utc - timedelta(days=1):
161
+ continue
162
+
163
+ try:
164
+ facts, malformed = parse_session_facts(jsonl_path)
165
+ except OSError:
166
+ skipped_files += 1
167
+ continue
168
+
169
+ malformed_total += malformed
170
+ for fact in facts:
171
+ if since_utc is not None and fact.occurred_at_utc < since_utc:
172
+ continue
173
+ if until_utc is not None and fact.occurred_at_utc >= until_utc:
174
+ continue
175
+ all_facts.append(fact)
176
+
177
+ return ReadResult(facts=all_facts, malformed_events=malformed_total, skipped_files=skipped_files)