coding-agent-cost 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {coding_agent_cost-0.2.1/coding_agent_cost.egg-info → coding_agent_cost-0.3.0}/PKG-INFO +16 -6
  2. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/README.md +15 -5
  3. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/__init__.py +1 -1
  4. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/facts.py +6 -2
  5. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/rates.json +35 -2
  6. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/readers/claude.py +25 -3
  7. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0/coding_agent_cost.egg-info}/PKG-INFO +16 -6
  8. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/coding_agent_cost.egg-info/SOURCES.txt +2 -0
  9. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/pyproject.toml +1 -1
  10. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_astra_pricing.py +2 -2
  11. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_cli.py +1 -0
  12. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_e0a_review3_hardening.py +3 -3
  13. coding_agent_cost-0.3.0/tests/test_reader_output_lower_bound.py +265 -0
  14. coding_agent_cost-0.3.0/tests/test_sonnet_5_5_rates.py +69 -0
  15. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_version_consistency.py +6 -6
  16. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/LICENSE +0 -0
  17. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/aggregate.py +0 -0
  18. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/cli.py +0 -0
  19. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/config.py +0 -0
  20. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/rates.py +0 -0
  21. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/readers/__init__.py +0 -0
  22. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/readers/codex.py +0 -0
  23. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/agent_cost/renderers.py +0 -0
  24. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/coding_agent_cost.egg-info/dependency_links.txt +0 -0
  25. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/coding_agent_cost.egg-info/entry_points.txt +0 -0
  26. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/coding_agent_cost.egg-info/top_level.txt +0 -0
  27. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/setup.cfg +0 -0
  28. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_aggregate.py +0 -0
  29. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_e0a_fable_5_1_and_sonnet_5_correction.py +0 -0
  30. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_facts.py +0 -0
  31. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_measure_v1_contract.py +0 -0
  32. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_opus_5_5_rates.py +0 -0
  33. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_rates.py +0 -0
  34. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_reader_claude.py +0 -0
  35. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_reader_codex.py +0 -0
  36. {coding_agent_cost-0.2.1 → coding_agent_cost-0.3.0}/tests/test_reader_dedup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-cost
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Estimate AI coding agent (Claude Code / Codex CLI) token usage and cost from local logs
5
5
  Author: shiki-yusuke
6
6
  License: MIT
@@ -73,7 +73,8 @@ agent-cost report
73
73
  The PyPI distribution is named [`coding-agent-cost`](https://pypi.org/project/coding-agent-cost/),
74
74
  while the command remains `agent-cost` and the import remains `agent_cost`. The shorter PyPI
75
75
  name is unavailable because of PyPI's similarity rule; this project is not affiliated with the
76
- unrelated `agentcost` distribution.
76
+ unrelated `agentcost` distribution. Releases are published from a `v*` tag by GitHub Actions
77
+ through PyPI's Trusted Publisher; see [docs/release.md](docs/release.md).
77
78
 
78
79
  ## Synthetic output example
79
80
 
@@ -231,6 +232,14 @@ network, never calls `gh`, and never resolves branches or PRs.
231
232
  flagged, since the reader's own cross-check of real transcripts found
232
233
  exactly that pattern in every observed duplicated group. See
233
234
  `CHANGELOG.md`'s 0.2.0 entry.
235
+ If the row a group adopts has no `stop_reason` (common in subagent
236
+ transcripts, where a message ending in `tool_use` may never get its final
237
+ line), its `output_tokens` is only the streaming head's value: the
238
+ group's `output` fact is flagged `source_quality: "output_lower_bound"`
239
+ -- the observed lower bound of that message's output tokens, not
240
+ guaranteed to be >= the true count. The group's input-side facts stay
241
+ `"ok"`, and the token amount itself is unchanged (still priced and
242
+ included in rows/totals); the flag is a warning only.
234
243
  When Anthropic's prompt-cache TTL breakdown (5-minute vs 1-hour writes) is
235
244
  present in the log, it's used; otherwise the cache-write tokens are priced
236
245
  at the 5-minute rate as an explicit **lower bound** and flagged
@@ -341,7 +350,7 @@ agent-cost measure --session-id <id> [--session-id <id> ...] \
341
350
  ```json
342
351
  {
343
352
  "protocol_version": "measure/v1",
344
- "producer_version": "0.2.1",
353
+ "producer_version": "0.3.0",
345
354
  "accounting_basis": "agent-cost-raw-total/v2",
346
355
  "generated_at": "...",
347
356
  "window": { "since": "...", "until": null },
@@ -362,7 +371,7 @@ agent-cost measure --session-id <id> [--session-id <id> ...] \
362
371
  "duplicate_rows_skipped": 0,
363
372
  "conflicting_duplicate_groups": 0,
364
373
  "missing_dedup_identity_rows": 0,
365
- "source_quality": { "ok": 41, "first_event_delta": 2, "identity_missing": 0 }
374
+ "source_quality": { "ok": 41, "first_event_delta": 2, "identity_missing": 0, "output_lower_bound": 1 }
366
375
  }
367
376
  }
368
377
  ```
@@ -394,8 +403,9 @@ includes absolute file paths, rollout paths, prompt/message content, or git
394
403
  branch names -- only the fields needed to reproduce a cost estimate:
395
404
  `occurred_at_utc`, `agent`, `session_id`, `model_raw`, `model_key`,
396
405
  `token_kind`, `tokens`, `mode`, and `source_quality` (a fixed-vocabulary
397
- caveat about how that one fact was derived, e.g. `"ok"` or Codex's
398
- `"first_event_delta"` -- never null).
406
+ caveat about how that one fact was derived: `"ok"`, Codex's
407
+ `"first_event_delta"`, or Claude's `"identity_missing"` /
408
+ `"output_lower_bound"` -- never null).
399
409
 
400
410
  ## License
401
411
 
@@ -47,7 +47,8 @@ agent-cost report
47
47
  The PyPI distribution is named [`coding-agent-cost`](https://pypi.org/project/coding-agent-cost/),
48
48
  while the command remains `agent-cost` and the import remains `agent_cost`. The shorter PyPI
49
49
  name is unavailable because of PyPI's similarity rule; this project is not affiliated with the
50
- unrelated `agentcost` distribution.
50
+ unrelated `agentcost` distribution. Releases are published from a `v*` tag by GitHub Actions
51
+ through PyPI's Trusted Publisher; see [docs/release.md](docs/release.md).
51
52
 
52
53
  ## Synthetic output example
53
54
 
@@ -205,6 +206,14 @@ network, never calls `gh`, and never resolves branches or PRs.
205
206
  flagged, since the reader's own cross-check of real transcripts found
206
207
  exactly that pattern in every observed duplicated group. See
207
208
  `CHANGELOG.md`'s 0.2.0 entry.
209
+ If the row a group adopts has no `stop_reason` (common in subagent
210
+ transcripts, where a message ending in `tool_use` may never get its final
211
+ line), its `output_tokens` is only the streaming head's value: the
212
+ group's `output` fact is flagged `source_quality: "output_lower_bound"`
213
+ -- the observed lower bound of that message's output tokens, not
214
+ guaranteed to be >= the true count. The group's input-side facts stay
215
+ `"ok"`, and the token amount itself is unchanged (still priced and
216
+ included in rows/totals); the flag is a warning only.
208
217
  When Anthropic's prompt-cache TTL breakdown (5-minute vs 1-hour writes) is
209
218
  present in the log, it's used; otherwise the cache-write tokens are priced
210
219
  at the 5-minute rate as an explicit **lower bound** and flagged
@@ -315,7 +324,7 @@ agent-cost measure --session-id <id> [--session-id <id> ...] \
315
324
  ```json
316
325
  {
317
326
  "protocol_version": "measure/v1",
318
- "producer_version": "0.2.1",
327
+ "producer_version": "0.3.0",
319
328
  "accounting_basis": "agent-cost-raw-total/v2",
320
329
  "generated_at": "...",
321
330
  "window": { "since": "...", "until": null },
@@ -336,7 +345,7 @@ agent-cost measure --session-id <id> [--session-id <id> ...] \
336
345
  "duplicate_rows_skipped": 0,
337
346
  "conflicting_duplicate_groups": 0,
338
347
  "missing_dedup_identity_rows": 0,
339
- "source_quality": { "ok": 41, "first_event_delta": 2, "identity_missing": 0 }
348
+ "source_quality": { "ok": 41, "first_event_delta": 2, "identity_missing": 0, "output_lower_bound": 1 }
340
349
  }
341
350
  }
342
351
  ```
@@ -368,8 +377,9 @@ includes absolute file paths, rollout paths, prompt/message content, or git
368
377
  branch names -- only the fields needed to reproduce a cost estimate:
369
378
  `occurred_at_utc`, `agent`, `session_id`, `model_raw`, `model_key`,
370
379
  `token_kind`, `tokens`, `mode`, and `source_quality` (a fixed-vocabulary
371
- caveat about how that one fact was derived, e.g. `"ok"` or Codex's
372
- `"first_event_delta"` -- never null).
380
+ caveat about how that one fact was derived: `"ok"`, Codex's
381
+ `"first_event_delta"`, or Claude's `"identity_missing"` /
382
+ `"output_lower_bound"` -- never null).
373
383
 
374
384
  ## License
375
385
 
@@ -6,4 +6,4 @@ against a versioned rate catalog. Everything happens locally; there are no
6
6
  network calls.
7
7
  """
8
8
 
9
- __version__ = "0.2.1"
9
+ __version__ = "0.3.0"
@@ -32,10 +32,14 @@ MODES = ("fast", "normal", "unknown")
32
32
  # (e.g. Codex's first delta in a rollout is measured against an assumed
33
33
  # zero baseline; Claude's reader can't dedup a row that lacks a full
34
34
  # (message.id, requestId) pair, so it emits that row individually and
35
- # flags it "identity_missing" rather than silently treating it as "ok").
35
+ # flags it "identity_missing" rather than silently treating it as "ok";
36
+ # "output_lower_bound" marks a Claude output fact whose tokens are only the
37
+ # observed lower bound of that message's output_tokens -- the reader's
38
+ # adopted row had no stop_reason, so the value is not guaranteed to be
39
+ # >= the true count).
36
40
  # This is never left unset -- every Fact defaults to "ok" so export never
37
41
  # emits a null source_quality.
38
- SOURCE_QUALITY_VALUES = ("ok", "first_event_delta", "identity_missing")
42
+ SOURCE_QUALITY_VALUES = ("ok", "first_event_delta", "identity_missing", "output_lower_bound")
39
43
 
40
44
  _BRACKET_SUFFIX = re.compile(r"\[[^\]]*\]$")
41
45
  _DATE_SUFFIX = re.compile(r"@\d{6,8}$")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": "1",
3
- "catalog_version": "2026-09-23",
3
+ "catalog_version": "2026-09-29",
4
4
  "currency": "USD",
5
5
  "unit": "per_mtok",
6
6
  "usd_per_credit": "0.04",
@@ -11,7 +11,8 @@
11
11
  "gpt-6-astra: Codex standard token-based rates only; not API pricing, legacy message metering, or actual contractual charges. Confirmed 2026-09-06 JST (2026-09-05T17:18:23Z). effective_from is this observation cutoff, NOT an official launch or price-start time; earlier events remain unpriced. No Codex long-context surcharge or cache-write charge is applied. Fast multiplier 2.5 applies to explicit priority request settings with matching owned turn/model context; explicit default uses Standard. Missing or ambiguous Astra modes remain unpriced. These settings do not confirm the backend processing tier. See docs/astra-pricing.md for compatibility limits and the separate GPT-5.6 discrepancy report.",
12
12
  "claude-fable-5-1 added 2026-09-09: rate_id claude-fable-5-1-local-first-observed-2026-09-02. effective_from (2026-09-02T01:17:55Z) is the timestamp the raw model ID was first observed across all 54 local transcript files (none found in August), not a confirmed official launch date -- if an authoritative launch date is confirmed later, add it as a separate rate_id with effective_until on this one rather than editing this period in place. Values (cache_read $0.25/MTok = 0.025x base input) differ from claude-fable-5's cache_read ($1.00/MTok = 0.1x); this is a genuine per-model price difference confirmed against the primary source (see sources below), not an inconsistency to reconcile.",
13
13
  "claude-sonnet-5 corrected 2026-09-09: a prior period (rate_id claude-sonnet-5-standard, effective_from 2026-09-01, values 3.0/0.30/3.75/6.0/15.0) priced a previously-announced rate increase that the pricing page's claude-sonnet-5-introductory-pricing note states did not occur (\"The previously scheduled increase to $3/$15 ... on September 1, 2026 will not occur\"). That period has been replaced by claude-sonnet-5-standard-2026-09-01, which carries the same $2/$0.20/$2.50/$4/$10 values as the preceding claude-sonnet-5-launch-promo period, since the launch price is confirmed to be the ongoing price with no change at the 2026-09-01 boundary. Any measure/v1 or report output computed against the pre-correction catalog for claude-sonnet-5 sessions occurring on or after 2026-09-01 overstated cost by 1.5x for that model. To detect this class of error going forward, cross-check each priced model's rate periods against its primary pricing page's Note text on a recurring (e.g. monthly) basis for mentions of a rate-change announcement being withdrawn or not taking effect -- a period can be internally valid (schema-wise) and still price a rate that was announced but never actually charged.",
14
- "claude-opus-5-5 added 2026-09-23: rate_id claude-opus-5-5-launch-2026-09-22. effective_from 2026-09-22T00:00:00Z is 00:00:00Z of the official public launch date (anthropic.com/claude-opus-5-5, 'September 22, 2026'); the launch time of day is not published, but a transcript row whose raw model ID is claude-opus-5-5 cannot predate the launch, so pricing from the start of that UTC day cannot price a pre-launch event. Values 4.0/0.20/5.0/8.0/20.0 per the pricing page: cache_read is 0.05x base input (pricing-page footnote 2), a genuine per-model difference from the standard 0.1x and from claude-fable-5-1's 0.025x. fast_multiplier 2.0 (fast mode $8/$40 per MTok on the pricing page). Claude Code's CHANGELOG entry for 2.1.280 states Opus 5.5 became the default model (see sources); rows whose raw model ID is claude-opus-5-5 would be unpriced without this entry. Claude Sonnet 5.5 and Claude Haiku 5.5 are announced to follow 'in the coming weeks' -- add them as separate entries when their model IDs and prices are published, never as aliases of the 5.x entries."
14
+ "claude-opus-5-5 added 2026-09-23: rate_id claude-opus-5-5-launch-2026-09-22. effective_from 2026-09-22T00:00:00Z is 00:00:00Z of the official public launch date (anthropic.com/claude-opus-5-5, 'September 22, 2026'); the launch time of day is not published, but a transcript row whose raw model ID is claude-opus-5-5 cannot predate the launch, so pricing from the start of that UTC day cannot price a pre-launch event. Values 4.0/0.20/5.0/8.0/20.0 per the pricing page: cache_read is 0.05x base input (pricing-page footnote 2), a genuine per-model difference from the standard 0.1x and from claude-fable-5-1's 0.025x. fast_multiplier 2.0 (fast mode $8/$40 per MTok on the pricing page). Claude Code's CHANGELOG entry for 2.1.280 states Opus 5.5 became the default model (see sources); rows whose raw model ID is claude-opus-5-5 would be unpriced without this entry. Claude Sonnet 5.5 and Claude Haiku 5.5 are announced to follow 'in the coming weeks' -- add them as separate entries when their model IDs and prices are published, never as aliases of the 5.x entries.",
15
+ "claude-sonnet-5-5 added 2026-09-29: rate_id claude-sonnet-5-5-launch-2026-09-28. effective_from 2026-09-28T00:00:00Z is 00:00:00Z of the official public launch date (anthropic.com/claude-sonnet-5-5, 'September 28, 2026'); as with claude-opus-5-5, the launch time of day is not published, but a transcript row whose raw model ID is claude-sonnet-5-5 cannot predate the launch, so pricing from the start of that UTC day cannot price a pre-launch event. Values 2.0/0.20/2.50/4.0/10.0 per the pricing page (Sonnet 5.5 row, no footnote: cache_read is the standard 0.1x, unlike claude-opus-5-5's 0.05x and claude-fable-5-1's 0.025x). Identical to claude-sonnet-5's ongoing values, but kept as a separate entry, never an alias: the pricing page lists the two rows independently and a future change to one need not apply to the other. fast_multiplier 1.0: the pricing page's fast-mode table lists only the Opus models. Claude Code's CHANGELOG entry for 2.1.284 states Sonnet 5.5 is now the default Sonnet model, so the `sonnet` alias in agent frontmatter resolves to it and rows whose raw model ID is claude-sonnet-5-5 are expected from that version onward; no such row existed in the local transcripts on 2026-09-29. Claude Haiku 5.5 is announced to follow 'in the coming weeks' -- add it as a separate entry when its model ID and prices are published."
15
16
  ],
16
17
  "sources": [
17
18
  {
@@ -81,6 +82,21 @@
81
82
  "url": "https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md",
82
83
  "retrieved_at": "2026-09-23",
83
84
  "note": "Claude Code 2.1.280 entry: Claude Opus 5.5 became the default model (1M context, $4/$20 per MTok, $0.20/MTok cache reads); the reason claude-opus-5-5 rows are expected in transcripts from that version"
85
+ },
86
+ {
87
+ "url": "https://platform.claude.com/docs/en/about-claude/pricing",
88
+ "retrieved_at": "2026-09-29",
89
+ "note": "used for claude-sonnet-5-5 (Sonnet 5.5 row: $2 / $2.50 / $4 / $0.20 / $10, no footnote so cache_read is the standard 0.1x; fast-mode table lists Opus models only); fetched via WebFetch, no local snapshot"
90
+ },
91
+ {
92
+ "url": "https://www.anthropic.com/claude-sonnet-5-5",
93
+ "retrieved_at": "2026-09-29",
94
+ "note": "Sonnet 5.5 announcement: public launch date September 28, 2026, model ID claude-sonnet-5-5, $2/$10 per MTok, Haiku 5.5 to follow in the coming weeks"
95
+ },
96
+ {
97
+ "url": "https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md",
98
+ "retrieved_at": "2026-09-29",
99
+ "note": "Claude Code 2.1.284 entry: added Claude Sonnet 5.5 (claude-sonnet-5-5), now the default Sonnet model; the reason claude-sonnet-5-5 rows are expected in transcripts from that version"
84
100
  }
85
101
  ],
86
102
  "models": [
@@ -264,6 +280,23 @@
264
280
  }
265
281
  ]
266
282
  },
283
+ {
284
+ "model_key": "claude-sonnet-5-5",
285
+ "aliases": [],
286
+ "fast_multiplier": "1.0",
287
+ "rates": [
288
+ {
289
+ "rate_id": "claude-sonnet-5-5-launch-2026-09-28",
290
+ "effective_from": "2026-09-28T00:00:00+00:00",
291
+ "effective_until": null,
292
+ "input_nocache": "2.0",
293
+ "cache_read": "0.20",
294
+ "cache_write_5m": "2.50",
295
+ "cache_write_1h": "4.0",
296
+ "output": "10.0"
297
+ }
298
+ ]
299
+ },
267
300
  {
268
301
  "model_key": "claude-haiku-4-5",
269
302
  "aliases": [],
@@ -278,6 +278,14 @@ def _resolve_mode(rows: list, adopted: dict) -> str:
278
278
  return adopted["mode"]
279
279
 
280
280
 
281
+ def _is_final(row: dict) -> bool:
282
+ """Whether a row carries a real ``message.stop_reason`` -- a non-empty
283
+ ``str``. Null, missing, ``False``, ``""`` and non-string values all
284
+ fail to qualify."""
285
+ stop_reason = row["stop_reason"]
286
+ return isinstance(stop_reason, str) and bool(stop_reason)
287
+
288
+
281
289
  def _select_adopted_row(rows: list) -> dict:
282
290
  """Pick the row within one dedup group whose usage gets emitted.
283
291
 
@@ -302,8 +310,7 @@ def _select_adopted_row(rows: list) -> dict:
302
310
  """
303
311
  adopted_idx = None
304
312
  for idx in range(len(rows) - 1, -1, -1):
305
- stop_reason = rows[idx]["stop_reason"]
306
- if isinstance(stop_reason, str) and stop_reason:
313
+ if _is_final(rows[idx]):
307
314
  adopted_idx = idx
308
315
  break
309
316
 
@@ -366,6 +373,19 @@ def parse_session_detailed(jsonl_path: Path) -> ClaudeParseResult:
366
373
  the group has exactly one concrete mode elsewhere, that concrete mode
367
374
  is emitted instead (see its docstring).
368
375
 
376
+ If the adopted row itself is not final (no non-empty-string
377
+ ``stop_reason`` -- see ``_is_final``), the group's ``output`` fact gets
378
+ ``source_quality="output_lower_bound"``: that row's ``output_tokens``
379
+ is a streaming head's value, observed to be a lower bound on the
380
+ message's real output count (subagent transcripts often end a
381
+ tool_use message without ever writing its final row). This keys off
382
+ the *adopted* row, not "does the group contain a final row", so a
383
+ final row overridden by a later, differing non-final row is flagged
384
+ too. The group's input-side facts stay ``"ok"`` (those fields don't
385
+ grow while streaming), and token amounts are emitted unchanged --
386
+ this only marks the caveat. Identity-missing rows never form a group
387
+ and keep ``"identity_missing"``.
388
+
369
389
  ``occurred_at_utc`` on the emitted fact is the *adopted* row's own
370
390
  timestamp (not the group's first-seen timestamp): the adopted row is
371
391
  what determines the actual token counts, and pricing/month-bucketing
@@ -493,6 +513,7 @@ def parse_session_detailed(jsonl_path: Path) -> ClaudeParseResult:
493
513
  if marker in standalone_rows:
494
514
  row = standalone_rows[marker]
495
515
  source_quality = "identity_missing"
516
+ output_source_quality = source_quality
496
517
  dedup_units.append(
497
518
  ClaudeDedupUnit(
498
519
  session_id=row["sid"],
@@ -544,6 +565,7 @@ def parse_session_detailed(jsonl_path: Path) -> ClaudeParseResult:
544
565
  "tokens": adopted["tokens"],
545
566
  }
546
567
  source_quality = "ok"
568
+ output_source_quality = "ok" if _is_final(adopted) else "output_lower_bound"
547
569
 
548
570
  model_key = normalize_model_key(row["model_raw"])
549
571
  for kind, amount in row["tokens"].items():
@@ -557,7 +579,7 @@ def parse_session_detailed(jsonl_path: Path) -> ClaudeParseResult:
557
579
  token_kind=kind,
558
580
  tokens=amount,
559
581
  mode=row["mode"],
560
- source_quality=source_quality,
582
+ source_quality=output_source_quality if kind == "output" else source_quality,
561
583
  )
562
584
  )
563
585
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-cost
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Estimate AI coding agent (Claude Code / Codex CLI) token usage and cost from local logs
5
5
  Author: shiki-yusuke
6
6
  License: MIT
@@ -73,7 +73,8 @@ agent-cost report
73
73
  The PyPI distribution is named [`coding-agent-cost`](https://pypi.org/project/coding-agent-cost/),
74
74
  while the command remains `agent-cost` and the import remains `agent_cost`. The shorter PyPI
75
75
  name is unavailable because of PyPI's similarity rule; this project is not affiliated with the
76
- unrelated `agentcost` distribution.
76
+ unrelated `agentcost` distribution. Releases are published from a `v*` tag by GitHub Actions
77
+ through PyPI's Trusted Publisher; see [docs/release.md](docs/release.md).
77
78
 
78
79
  ## Synthetic output example
79
80
 
@@ -231,6 +232,14 @@ network, never calls `gh`, and never resolves branches or PRs.
231
232
  flagged, since the reader's own cross-check of real transcripts found
232
233
  exactly that pattern in every observed duplicated group. See
233
234
  `CHANGELOG.md`'s 0.2.0 entry.
235
+ If the row a group adopts has no `stop_reason` (common in subagent
236
+ transcripts, where a message ending in `tool_use` may never get its final
237
+ line), its `output_tokens` is only the streaming head's value: the
238
+ group's `output` fact is flagged `source_quality: "output_lower_bound"`
239
+ -- the observed lower bound of that message's output tokens, not
240
+ guaranteed to be >= the true count. The group's input-side facts stay
241
+ `"ok"`, and the token amount itself is unchanged (still priced and
242
+ included in rows/totals); the flag is a warning only.
234
243
  When Anthropic's prompt-cache TTL breakdown (5-minute vs 1-hour writes) is
235
244
  present in the log, it's used; otherwise the cache-write tokens are priced
236
245
  at the 5-minute rate as an explicit **lower bound** and flagged
@@ -341,7 +350,7 @@ agent-cost measure --session-id <id> [--session-id <id> ...] \
341
350
  ```json
342
351
  {
343
352
  "protocol_version": "measure/v1",
344
- "producer_version": "0.2.1",
353
+ "producer_version": "0.3.0",
345
354
  "accounting_basis": "agent-cost-raw-total/v2",
346
355
  "generated_at": "...",
347
356
  "window": { "since": "...", "until": null },
@@ -362,7 +371,7 @@ agent-cost measure --session-id <id> [--session-id <id> ...] \
362
371
  "duplicate_rows_skipped": 0,
363
372
  "conflicting_duplicate_groups": 0,
364
373
  "missing_dedup_identity_rows": 0,
365
- "source_quality": { "ok": 41, "first_event_delta": 2, "identity_missing": 0 }
374
+ "source_quality": { "ok": 41, "first_event_delta": 2, "identity_missing": 0, "output_lower_bound": 1 }
366
375
  }
367
376
  }
368
377
  ```
@@ -394,8 +403,9 @@ includes absolute file paths, rollout paths, prompt/message content, or git
394
403
  branch names -- only the fields needed to reproduce a cost estimate:
395
404
  `occurred_at_utc`, `agent`, `session_id`, `model_raw`, `model_key`,
396
405
  `token_kind`, `tokens`, `mode`, and `source_quality` (a fixed-vocabulary
397
- caveat about how that one fact was derived, e.g. `"ok"` or Codex's
398
- `"first_event_delta"` -- never null).
406
+ caveat about how that one fact was derived: `"ok"`, Codex's
407
+ `"first_event_delta"`, or Claude's `"identity_missing"` /
408
+ `"output_lower_bound"` -- never null).
399
409
 
400
410
  ## License
401
411
 
@@ -29,4 +29,6 @@ tests/test_rates.py
29
29
  tests/test_reader_claude.py
30
30
  tests/test_reader_codex.py
31
31
  tests/test_reader_dedup.py
32
+ tests/test_reader_output_lower_bound.py
33
+ tests/test_sonnet_5_5_rates.py
32
34
  tests/test_version_consistency.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coding-agent-cost"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Estimate AI coding agent (Claude Code / Codex CLI) token usage and cost from local logs"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -45,7 +45,7 @@ def test_standard_fast_and_unknown_mode_prices(kind, usd, credits, mode, multipl
45
45
 
46
46
  def test_catalog_observation_boundary_is_not_backdated():
47
47
  catalog = load_rates()
48
- assert catalog.catalog_version == "2026-09-23"
48
+ assert catalog.catalog_version == "2026-09-29"
49
49
  entry = catalog.models["gpt-6-astra"]
50
50
  assert entry.aliases == ()
51
51
  assert entry.rates[0].effective_from == CUTOFF
@@ -149,7 +149,7 @@ def test_measure_reads_isolated_synthetic_astra_with_existing_contract(tmp_path,
149
149
  assert cli.main(["measure", "--session-id", "synthetic-astra", "--agent", "codex"]) == 0
150
150
  report = json.loads(capsys.readouterr().out)
151
151
  assert report["protocol_version"] == "measure/v1"
152
- assert report["rates"]["catalog_version"] == "2026-09-23"
152
+ assert report["rates"]["catalog_version"] == "2026-09-29"
153
153
  assert len(report["rates"]["sha256"]) == 64
154
154
  assert report["sessions"]["synthetic-astra"]["totals"]["estimated_cost_usd"] == 122
155
155
  assert report["total"]["totals"]["credits"] == 3050
@@ -471,6 +471,7 @@ def test_measure_unknown_session_id_exits_zero_with_empty_result(tmp_path, monke
471
471
  "ok": 0,
472
472
  "first_event_delta": 0,
473
473
  "identity_missing": 0,
474
+ "output_lower_bound": 0,
474
475
  }
475
476
 
476
477
 
@@ -19,7 +19,7 @@ architect レビュー (3 巡目) と builder レビューで、既存テスト
19
19
  かつ確定金額が計上されない。境界ちょうどは priced になる。
20
20
 
21
21
  条件D (PR レビュー3点目・追加分): catalog は
22
- catalog_version == "2026-09-23"(rates.json の現行値)であり、sources の中に url
22
+ catalog_version == "2026-09-29"(rates.json の現行値)であり、sources の中に url
23
23
  "https://platform.claude.com/docs/en/about-claude/pricing" の
24
24
  entry があって、その note に
25
25
  "sha256=d79ad28567196bd55dd50e2fd89341b9da9774a45c1d02fcf387078507fd15e0"
@@ -224,7 +224,7 @@ def test_condition_c_claude_fable_5_1_at_effective_from_boundary_is_priced_contr
224
224
 
225
225
 
226
226
  def test_condition_d_catalog_version_and_pricing_source_sha256_note():
227
- """条件D: catalog_version == "2026-09-23"(rates.json の現行値)であり、sources に url
227
+ """条件D: catalog_version == "2026-09-29"(rates.json の現行値)であり、sources に url
228
228
  "https://platform.claude.com/docs/en/about-claude/pricing" の entry が
229
229
  あって、その note に指定の sha256 文字列を含むこと。
230
230
 
@@ -233,7 +233,7 @@ def test_condition_d_catalog_version_and_pricing_source_sha256_note():
233
233
  ダイジェストを貼り付けてしまう。
234
234
  """
235
235
  catalog = load_rates()
236
- assert catalog.catalog_version == "2026-09-23"
236
+ assert catalog.catalog_version == "2026-09-29"
237
237
 
238
238
  matches = [
239
239
  s
@@ -0,0 +1,265 @@
1
+ """Tests for ``source_quality="output_lower_bound"`` (E0-a4 S1).
2
+
3
+ Spec: when the row a dedup group adopts (``_select_adopted_row``) has no
4
+ valid ``message.stop_reason`` (a non-empty ``str``), that group's
5
+ ``token_kind="output"`` fact is flagged ``"output_lower_bound"`` -- its
6
+ ``output_tokens`` is the streaming head's value, a lower bound on the true
7
+ count. Input-side facts from the same group stay ``"ok"``. The decision is
8
+ "is the *adopted* row final", not "does the group contain a final row", so
9
+ a final row followed by a differing non-final row (adoption switches to
10
+ the last row) is flagged too. Identity-missing standalone rows keep
11
+ ``"identity_missing"``. Token amounts, adoption and conflict counting are
12
+ unchanged -- only the flag is added.
13
+
14
+ All fixtures are synthetic and written to ``tmp_path``.
15
+ """
16
+
17
+ import json
18
+
19
+ import pytest
20
+
21
+ from agent_cost import cli
22
+ from agent_cost.facts import SOURCE_QUALITY_VALUES
23
+ from agent_cost.readers.claude import parse_session_detailed
24
+
25
+ _ABSENT = object()
26
+
27
+
28
+ def _event(
29
+ ts,
30
+ output,
31
+ *,
32
+ stop_reason=_ABSENT,
33
+ msg_id="msg-1",
34
+ req_id="req-1",
35
+ session_id="s-olb",
36
+ input_tokens=10,
37
+ cache_read=20,
38
+ cache_write_5m=30,
39
+ ):
40
+ message = {
41
+ "id": msg_id,
42
+ "model": "claude-sonnet-5",
43
+ "usage": {
44
+ "input_tokens": input_tokens,
45
+ "cache_read_input_tokens": cache_read,
46
+ "cache_creation_input_tokens": cache_write_5m,
47
+ "cache_creation": {"ephemeral_5m_input_tokens": cache_write_5m, "ephemeral_1h_input_tokens": 0},
48
+ "output_tokens": output,
49
+ },
50
+ }
51
+ if msg_id is None:
52
+ del message["id"]
53
+ if stop_reason is not _ABSENT:
54
+ message["stop_reason"] = stop_reason
55
+ event = {
56
+ "type": "assistant",
57
+ "timestamp": ts,
58
+ "sessionId": session_id,
59
+ "requestId": req_id,
60
+ "message": message,
61
+ }
62
+ if req_id is None:
63
+ del event["requestId"]
64
+ return event
65
+
66
+
67
+ def _write(path, events):
68
+ path.write_text("\n".join(json.dumps(e) for e in events) + "\n")
69
+ return path
70
+
71
+
72
+ def _parse(tmp_path, events):
73
+ return parse_session_detailed(_write(tmp_path / "session.jsonl", events))
74
+
75
+
76
+ def _quality_by_kind(facts):
77
+ return {f.token_kind: f.source_quality for f in facts}
78
+
79
+
80
+ def _tokens_by_kind(facts):
81
+ return {f.token_kind: f.tokens for f in facts}
82
+
83
+
84
+ INPUT_KINDS = ("input_nocache", "cache_read", "cache_write_5m")
85
+
86
+
87
+ def test_output_lower_bound_is_last_source_quality_value():
88
+ assert SOURCE_QUALITY_VALUES[-1] == "output_lower_bound"
89
+ assert SOURCE_QUALITY_VALUES[:3] == ("ok", "first_event_delta", "identity_missing")
90
+
91
+
92
+ def test_group_without_final_row_flags_only_output(tmp_path):
93
+ """AC1: no row in the group has a stop_reason -> adopted = last row,
94
+ its output fact is output_lower_bound, input-side facts stay ok."""
95
+ result = _parse(
96
+ tmp_path,
97
+ [
98
+ _event("2026-10-01T00:00:00Z", 1, stop_reason=None),
99
+ _event("2026-10-01T00:00:01Z", 1),
100
+ ],
101
+ )
102
+ quality = _quality_by_kind(result.facts)
103
+ assert quality["output"] == "output_lower_bound"
104
+ for kind in INPUT_KINDS:
105
+ assert quality[kind] == "ok"
106
+ assert _tokens_by_kind(result.facts) == {"input_nocache": 10, "cache_read": 20, "cache_write_5m": 30, "output": 1}
107
+ assert result.duplicate_rows_skipped == 1
108
+ assert result.conflicting_duplicate_groups == 0
109
+
110
+
111
+ def test_single_row_group_without_final_flags_output(tmp_path):
112
+ result = _parse(tmp_path, [_event("2026-10-01T00:00:00Z", 4)])
113
+ quality = _quality_by_kind(result.facts)
114
+ assert quality["output"] == "output_lower_bound"
115
+ for kind in INPUT_KINDS:
116
+ assert quality[kind] == "ok"
117
+
118
+
119
+ def test_group_ending_in_final_row_is_all_ok(tmp_path):
120
+ """AC2: the last row carries a stop_reason -> every fact is ok."""
121
+ result = _parse(
122
+ tmp_path,
123
+ [
124
+ _event("2026-10-01T00:00:00Z", 1),
125
+ _event("2026-10-01T00:00:01Z", 2),
126
+ _event("2026-10-01T00:00:02Z", 57, stop_reason="tool_use"),
127
+ ],
128
+ )
129
+ assert {f.source_quality for f in result.facts} == {"ok"}
130
+ assert _tokens_by_kind(result.facts)["output"] == 57
131
+
132
+
133
+ def test_final_row_followed_by_differing_row_flags_output(tmp_path):
134
+ """AC2b: a final row followed by a different non-final row switches
135
+ adoption to the last row (no stop_reason) -> output_lower_bound."""
136
+ result = _parse(
137
+ tmp_path,
138
+ [
139
+ _event("2026-10-01T00:00:00Z", 5, stop_reason="end_turn"),
140
+ _event("2026-10-01T00:00:01Z", 8),
141
+ ],
142
+ )
143
+ quality = _quality_by_kind(result.facts)
144
+ assert quality["output"] == "output_lower_bound"
145
+ for kind in INPUT_KINDS:
146
+ assert quality[kind] == "ok"
147
+ assert _tokens_by_kind(result.facts)["output"] == 8
148
+
149
+
150
+ def test_final_row_followed_by_identical_row_stays_ok(tmp_path):
151
+ """AC2c: a final row followed by an identical (non-final) row keeps the
152
+ final row adopted -> ok, even though the group's last row isn't final."""
153
+ result = _parse(
154
+ tmp_path,
155
+ [
156
+ _event("2026-10-01T00:00:00Z", 5, stop_reason="end_turn"),
157
+ _event("2026-10-01T00:00:01Z", 5),
158
+ ],
159
+ )
160
+ assert {f.source_quality for f in result.facts} == {"ok"}
161
+ assert _tokens_by_kind(result.facts)["output"] == 5
162
+
163
+
164
+ @pytest.mark.parametrize("stop_reason", ["", None, 0, 1, 1.5, False, ["end_turn"], _ABSENT])
165
+ def test_invalid_stop_reason_only_group_flags_output(tmp_path, stop_reason):
166
+ """AC2c: stop_reason "" / None / numbers / non-str only -> not final."""
167
+ result = _parse(
168
+ tmp_path,
169
+ [
170
+ _event("2026-10-01T00:00:00Z", 3, stop_reason=stop_reason),
171
+ _event("2026-10-01T00:00:01Z", 6, stop_reason=stop_reason),
172
+ ],
173
+ )
174
+ quality = _quality_by_kind(result.facts)
175
+ assert quality["output"] == "output_lower_bound"
176
+ for kind in INPUT_KINDS:
177
+ assert quality[kind] == "ok"
178
+ assert _tokens_by_kind(result.facts)["output"] == 6
179
+
180
+
181
+ def test_final_row_with_zero_output_is_ok(tmp_path):
182
+ """AC2c: a final row with output 0 emits no output fact; the rest is ok."""
183
+ result = _parse(
184
+ tmp_path,
185
+ [
186
+ _event("2026-10-01T00:00:00Z", 0),
187
+ _event("2026-10-01T00:00:01Z", 0, stop_reason="end_turn"),
188
+ ],
189
+ )
190
+ assert "output" not in _tokens_by_kind(result.facts)
191
+ assert {f.source_quality for f in result.facts} == {"ok"}
192
+
193
+
194
+ @pytest.mark.parametrize("missing", ["msg_id", "req_id"])
195
+ def test_identity_missing_row_stays_identity_missing(tmp_path, missing):
196
+ """AC2d: a standalone row without a full id pair is not a group, so it
197
+ keeps identity_missing on every fact (including output)."""
198
+ kwargs = {missing: None}
199
+ result = _parse(tmp_path, [_event("2026-10-01T00:00:00Z", 9, **kwargs)])
200
+ assert result.missing_dedup_identity_rows == 1
201
+ assert "output" in _tokens_by_kind(result.facts)
202
+ assert {f.source_quality for f in result.facts} == {"identity_missing"}
203
+
204
+
205
+ def test_flag_is_per_group(tmp_path):
206
+ """One group without a final row, one with: only the former's output is flagged."""
207
+ result = _parse(
208
+ tmp_path,
209
+ [
210
+ _event("2026-10-01T00:00:00Z", 2, msg_id="msg-a", req_id="req-a"),
211
+ _event("2026-10-01T00:00:01Z", 40, msg_id="msg-b", req_id="req-b", stop_reason="end_turn"),
212
+ ],
213
+ )
214
+ output_facts = [f for f in result.facts if f.token_kind == "output"]
215
+ assert [(f.tokens, f.source_quality) for f in output_facts] == [(2, "output_lower_bound"), (40, "ok")]
216
+ assert all(f.source_quality == "ok" for f in result.facts if f.token_kind != "output")
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # CLI (AC3): measure's data_quality.source_quality always carries the key.
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ def _setup_claude_session(tmp_path, monkeypatch, events):
225
+ claude_home = tmp_path / "claude_home"
226
+ codex_home = tmp_path / "codex_home"
227
+ project_dir = claude_home / "projects" / "olb"
228
+ project_dir.mkdir(parents=True)
229
+ codex_home.mkdir()
230
+ monkeypatch.setenv("CLAUDE_HOME", str(claude_home))
231
+ monkeypatch.setenv("CODEX_HOME", str(codex_home))
232
+ monkeypatch.delenv("AGENT_COST_CONFIG", raising=False)
233
+ _write(project_dir / "session.jsonl", events)
234
+
235
+
236
+ def _measure_source_quality(capsys):
237
+ rc = cli.main(["measure", "--session-id", "s-olb", "--since", "2026-09-01"])
238
+ assert rc == 0
239
+ return json.loads(capsys.readouterr().out)["data_quality"]["source_quality"]
240
+
241
+
242
+ def test_measure_counts_output_lower_bound(tmp_path, monkeypatch, capsys):
243
+ _setup_claude_session(
244
+ tmp_path,
245
+ monkeypatch,
246
+ [
247
+ _event("2026-10-01T00:00:00Z", 1, msg_id="msg-a", req_id="req-a"),
248
+ _event("2026-10-01T00:00:01Z", 30, msg_id="msg-b", req_id="req-b", stop_reason="end_turn"),
249
+ ],
250
+ )
251
+ sq = _measure_source_quality(capsys)
252
+ assert set(sq.keys()) == set(SOURCE_QUALITY_VALUES)
253
+ assert sq["output_lower_bound"] == 1
254
+ assert sq["ok"] == 7
255
+ assert sq["identity_missing"] == 0
256
+
257
+
258
+ def test_measure_reports_zero_output_lower_bound_when_absent(tmp_path, monkeypatch, capsys):
259
+ _setup_claude_session(
260
+ tmp_path,
261
+ monkeypatch,
262
+ [_event("2026-10-01T00:00:00Z", 30, stop_reason="end_turn")],
263
+ )
264
+ sq = _measure_source_quality(capsys)
265
+ assert sq == {"ok": 4, "first_event_delta": 0, "identity_missing": 0, "output_lower_bound": 0}
@@ -0,0 +1,69 @@
1
+ """Acceptance tests for the claude-sonnet-5-5 rate entry (catalog_version 2026-09-29).
2
+
3
+ Primary sources: platform.claude.com/docs/en/about-claude/pricing (Sonnet 5.5
4
+ row, no cache_read footnote), anthropic.com/claude-sonnet-5-5 (launch
5
+ 2026-09-28), Claude Code CHANGELOG 2.1.284 (Sonnet 5.5 is the default Sonnet).
6
+ """
7
+
8
+ from datetime import datetime, timezone
9
+ from decimal import Decimal
10
+
11
+ from agent_cost.rates import load_rates
12
+
13
+ UTC = timezone.utc
14
+
15
+
16
+ def dt(s):
17
+ return datetime.fromisoformat(s).replace(tzinfo=UTC)
18
+
19
+
20
+ def test_claude_sonnet_5_5_resolves_with_pricing_page_values_on_or_after_launch():
21
+ catalog = load_rates()
22
+ for occurred_at in ("2026-09-28T00:00:00+00:00", "2026-12-01T00:00:00+00:00"):
23
+ resolved, period = catalog.rate_for("claude-sonnet-5-5", dt(occurred_at))
24
+ assert resolved == "claude-sonnet-5-5"
25
+ assert period is not None
26
+ assert period.rate_id == "claude-sonnet-5-5-launch-2026-09-28"
27
+ assert period.values["input_nocache"] == Decimal("2.0")
28
+ assert period.values["cache_read"] == Decimal("0.20")
29
+ assert period.values["cache_write_5m"] == Decimal("2.50")
30
+ assert period.values["cache_write_1h"] == Decimal("4.0")
31
+ assert period.values["output"] == Decimal("10.0")
32
+
33
+
34
+ def test_claude_sonnet_5_5_is_unpriced_before_launch_date():
35
+ catalog = load_rates()
36
+ resolved, before = catalog.rate_for("claude-sonnet-5-5", dt("2026-09-27T23:59:59+00:00"))
37
+ assert resolved == "claude-sonnet-5-5"
38
+ assert before is None
39
+
40
+
41
+ def test_claude_sonnet_5_5_is_its_own_entry_not_an_alias_of_sonnet_5():
42
+ # Same values as claude-sonnet-5 today, but the pricing page lists the rows
43
+ # independently; a future change to one must not silently apply to the other.
44
+ catalog = load_rates()
45
+ assert "claude-sonnet-5-5" in catalog.models
46
+ assert catalog.models["claude-sonnet-5-5"].aliases == ()
47
+ assert "claude-sonnet-5-5" not in catalog.models["claude-sonnet-5"].aliases
48
+ _, s5 = catalog.rate_for("claude-sonnet-5", dt("2026-09-28T00:00:00+00:00"))
49
+ _, s55 = catalog.rate_for("claude-sonnet-5-5", dt("2026-09-28T00:00:00+00:00"))
50
+ assert s5.rate_id != s55.rate_id
51
+ assert s5.values == s55.values
52
+
53
+
54
+ def test_claude_sonnet_5_5_cache_read_is_standard_multiplier_and_no_fast_mode():
55
+ catalog = load_rates()
56
+ entry = catalog.models["claude-sonnet-5-5"]
57
+ assert entry.fast_multiplier == Decimal("1.0")
58
+ period = entry.rates[0]
59
+ assert period.values["cache_read"] == period.values["input_nocache"] * Decimal("0.1")
60
+ assert period.values["cache_write_5m"] == period.values["input_nocache"] * Decimal("1.25")
61
+ assert period.values["cache_write_1h"] == period.values["input_nocache"] * Decimal("2")
62
+
63
+
64
+ def test_catalog_version_and_sources_record_the_sonnet_5_5_primary_sources():
65
+ catalog = load_rates()
66
+ assert catalog.catalog_version == "2026-09-29"
67
+ urls = {(s.get("url"), s.get("retrieved_at")) for s in catalog.sources}
68
+ assert ("https://platform.claude.com/docs/en/about-claude/pricing", "2026-09-29") in urls
69
+ assert ("https://www.anthropic.com/claude-sonnet-5-5", "2026-09-29") in urls
@@ -1,8 +1,8 @@
1
1
  """Spec: pyproject.toml's [project].version and agent_cost.__version__ must
2
- agree, and both must read "0.2.1" for this change (the Opus 5.5 catalog release bumps the package
3
- version alongside the measure/v1 data_quality additions). A broken
4
- implementation would bump one file but not the other, or forget the bump
5
- entirely.
2
+ agree, and both must read "0.3.0" for this change (the output_lower_bound
3
+ source_quality value is an additive vocabulary change, so it bumps the minor
4
+ version). A broken implementation would bump one file but not the other, or
5
+ forget the bump entirely.
6
6
 
7
7
  Uses a plain regex instead of tomllib/tomli: this repo is dependency-free
8
8
  by design (pyproject.toml's own dependencies = []) and tomllib is
@@ -29,5 +29,5 @@ def test_pyproject_version_matches_package_version():
29
29
 
30
30
 
31
31
  def test_version_is_0_2_0():
32
- assert agent_cost.__version__ == "0.2.1"
33
- assert _pyproject_version() == "0.2.1"
32
+ assert agent_cost.__version__ == "0.3.0"
33
+ assert _pyproject_version() == "0.3.0"