bmsdna-devtools 0.42.0__tar.gz → 0.43.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 (112) hide show
  1. bmsdna_devtools-0.42.0/README.md → bmsdna_devtools-0.43.0/PKG-INFO +52 -0
  2. bmsdna_devtools-0.42.0/PKG-INFO → bmsdna_devtools-0.43.0/README.md +35 -14
  3. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli.py +3 -0
  4. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/__init__.py +5 -0
  5. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/cli.py +122 -0
  6. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pipeline.py +255 -0
  7. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pricing.py +59 -0
  8. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/report.py +334 -0
  9. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/00_schema.sql +18 -0
  10. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_claude.sql +83 -0
  11. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_codex.sql +92 -0
  12. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/02_messages.sql +105 -0
  13. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/03_markers.sql +67 -0
  14. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/04_facts.sql +78 -0
  15. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/05_views.sql +102 -0
  16. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/pyproject.toml +5 -1
  17. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/skills/bmsdna-devtools/SKILL.md +7 -0
  18. bmsdna_devtools-0.43.0/tests/test_usage.py +474 -0
  19. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/uv.lock +45 -2
  20. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.github/workflows/auto-release.yml +0 -0
  21. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-publish.yml +0 -0
  22. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-test.yml +0 -0
  23. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.gitignore +0 -0
  24. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.python-version +0 -0
  25. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/__init__.py +0 -0
  26. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/_api_dump.py +0 -0
  27. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_auth.py +0 -0
  28. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_issue.py +0 -0
  29. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/agent_mentions.py +0 -0
  30. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/api_usage.py +0 -0
  31. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/app_service_logs.py +0 -0
  32. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/bdt_config.py +0 -0
  33. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli_tools.py +0 -0
  34. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/commit.py +0 -0
  35. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/dead_code.py +0 -0
  36. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_config.py +0 -0
  37. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_files.py +0 -0
  38. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_injection.py +0 -0
  39. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_repo.py +0 -0
  40. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_issue.py +0 -0
  41. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_pr.py +0 -0
  42. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gitrepo.py +0 -0
  43. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_frontend.py +0 -0
  44. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_python.py +0 -0
  45. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_do.py +0 -0
  46. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_show.py +0 -0
  47. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_watch.py +0 -0
  48. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint.py +0 -0
  49. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_findings.py +0 -0
  50. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_models.py +0 -0
  51. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql.py +0 -0
  52. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  53. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_tooling.py +0 -0
  54. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_typescript.py +0 -0
  55. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/logs.py +0 -0
  56. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_build.py +0 -0
  57. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_choice.py +0 -0
  58. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_info.py +0 -0
  59. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  60. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_labels.py +0 -0
  61. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_markdown.py +0 -0
  62. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pull.py +0 -0
  63. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/testdb.py +0 -0
  64. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/translate.py +0 -0
  65. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/worktree.py +0 -0
  66. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/justfile +0 -0
  67. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/conftest.py +0 -0
  68. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_ado_auth.py +0 -0
  69. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue.py +0 -0
  70. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue_flow.py +0 -0
  71. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_agent_mentions.py +0 -0
  72. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_api_usage.py +0 -0
  73. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_app_service_logs.py +0 -0
  74. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_bdt_config.py +0 -0
  75. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_cleanup.py +0 -0
  76. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_commit.py +0 -0
  77. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_issue.py +0 -0
  78. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_create.py +0 -0
  79. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_id.py +0 -0
  80. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pull.py +0 -0
  81. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_tools.py +0 -0
  82. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_commit.py +0 -0
  83. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_env_config.py +0 -0
  84. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_env_files.py +0 -0
  85. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_find_injection.py +0 -0
  86. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_find_repo.py +0 -0
  87. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_gh_issue.py +0 -0
  88. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_gh_pr.py +0 -0
  89. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_gitrepo.py +0 -0
  90. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_do.py +0 -0
  91. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_for_me.py +0 -0
  92. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_show.py +0 -0
  93. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_watch.py +0 -0
  94. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint.py +0 -0
  95. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_models.py +0 -0
  96. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql.py +0 -0
  97. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql_files.py +0 -0
  98. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_tooling.py +0 -0
  99. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_typescript.py +0 -0
  100. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build.py +0 -0
  101. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build_flow.py +0 -0
  102. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_choice.py +0 -0
  103. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_description_not_truncated.py +0 -0
  104. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_info.py +0 -0
  105. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_issue_link.py +0 -0
  106. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_labels.py +0 -0
  107. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_markdown.py +0 -0
  108. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pull.py +0 -0
  109. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_testdb.py +0 -0
  110. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_translate.py +0 -0
  111. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_wait_timeout.py +0 -0
  112. {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_worktree_clean.py +0 -0
@@ -1,3 +1,20 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.43.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pgdevkit[db]>=0.7.1
7
+ Requires-Dist: requests>=2.32.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
10
+ Requires-Dist: typer>=0.26.7
11
+ Provides-Extra: lint
12
+ Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
+ Provides-Extra: usage
14
+ Requires-Dist: duckdb>=1.3.0; extra == 'usage'
15
+ Requires-Dist: plotly>=5.24.0; extra == 'usage'
16
+ Description-Content-Type: text/markdown
17
+
1
18
  # bmsdna-devtools
2
19
 
3
20
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -29,6 +46,8 @@ uv tool install bmsdna-devtools
29
46
 
30
47
  Or as a project dependency: `uv add bmsdna-devtools`.
31
48
 
49
+ Optional extras: `bmsdna-devtools[lint]` (`bdt lint` SQL rules), `bmsdna-devtools[usage]` (`bdt usage`, below).
50
+
32
51
  ## `bdt pr status`
33
52
 
34
53
  Find the PR opened from the current branch and report build/check status
@@ -1024,6 +1043,39 @@ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproj
1024
1043
  Identical findings on the same line are reported once. Silence a confirmed-safe finding with
1025
1044
  `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
1026
1045
 
1046
+ ## `bdt usage analyze` / `bdt usage sql`
1047
+
1048
+ Where do the tokens of your Claude Code and codex sessions go, per step of the `dev-workflow`? Needs the extra:
1049
+ `uv tool install 'bmsdna-devtools[usage]'` (DuckDB reads the logs, plotly draws the report; neither is imported by other commands).
1050
+
1051
+ ```bash
1052
+ bdt usage analyze [--scope workflow|all] [--since 2026-10-01] [--price gpt-6-sol=1.25,10,0.125] [--no-codex] [--rebuild]
1053
+ bdt usage sql "select label, round(pct_cost, 1) from v_steps" # ad hoc SQL on the last analysis
1054
+ ```
1055
+
1056
+ It reads `~/.claude/projects` (`--logs`) and `~/.codex/sessions` (`--codex-logs`, skipped if missing), prints a summary and
1057
+ writes `report.html` (charts plus a table view, light/dark) with CSV / Parquet exports next to the database
1058
+ (`~/.cache/bdt/usage/`, or `--db` / `--out`). The report is self-contained and contains your session titles and agent
1059
+ descriptions, so look at it before sharing it.
1060
+
1061
+ - **Steps:** setup, plan, plan review, implement, test, screenshots, design review, quick review, full review, publish / CI.
1062
+ Every model turn is assigned to one by markers in its tool calls: skill calls (`bms-code-review --quick|--full`,
1063
+ `design-review`, `bms-plan-review`; for codex, reading the skill's `SKILL.md`), `bdt` commands, edits (`apply_patch` for
1064
+ codex), pytest / lint, screenshot tooling, image reads. A step stays until the next marker; test runs only count for
1065
+ the turn that runs them. Subagents inherit the step of the turn that spawned them. Only threads that follow the workflow
1066
+ (and what they spawn) are counted, `--scope all` takes every session. It is a heuristic: read differences of a few percent
1067
+ as noise. The rules are SQL in `bmsdna/devtools/usage/sql/03_markers.sql`.
1068
+ - **Numbers:** cost is the API list price (cache writes 1.25x / 2x, cache reads, output, per model), not your actual bill;
1069
+ "% tokens" counts cache reads too, "% fresh tokens" does not. The report also estimates what screenshots cost by sitting in
1070
+ the context of every later turn.
1071
+ - **codex:** a codex run that Claude started with `codex exec` / `codex review` (same directory, within 15 minutes) is counted
1072
+ in that Claude session and step, so reviews run by codex show up under "Review" even though Claude's own log doesn't contain
1073
+ them; codex subagents follow their parent thread. codex models have no built-in price: give one with
1074
+ `--price MODEL=INPUT,OUTPUT,CACHED` (USD per million tokens), repeatable. Turns without a price are counted in tokens
1075
+ but missing from every cost number, and the summary says how many.
1076
+ - **Cache:** parsed records are kept in the database, so the next run only parses new or changed log files (a few seconds).
1077
+ `--rebuild` re-reads everything. Message text is not stored.
1078
+
1027
1079
  ## Releasing
1028
1080
 
1029
1081
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bmsdna-devtools
3
- Version: 0.42.0
4
- Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
- Requires-Python: >=3.14
6
- Requires-Dist: pgdevkit[db]>=0.7.1
7
- Requires-Dist: requests>=2.32.0
8
- Requires-Dist: rich>=13.0.0
9
- Requires-Dist: tomli-w>=1.0.0
10
- Requires-Dist: typer>=0.26.7
11
- Provides-Extra: lint
12
- Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
- Description-Content-Type: text/markdown
14
-
15
1
  # bmsdna-devtools
16
2
 
17
3
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -43,6 +29,8 @@ uv tool install bmsdna-devtools
43
29
 
44
30
  Or as a project dependency: `uv add bmsdna-devtools`.
45
31
 
32
+ Optional extras: `bmsdna-devtools[lint]` (`bdt lint` SQL rules), `bmsdna-devtools[usage]` (`bdt usage`, below).
33
+
46
34
  ## `bdt pr status`
47
35
 
48
36
  Find the PR opened from the current branch and report build/check status
@@ -1038,6 +1026,39 @@ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproj
1038
1026
  Identical findings on the same line are reported once. Silence a confirmed-safe finding with
1039
1027
  `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
1040
1028
 
1029
+ ## `bdt usage analyze` / `bdt usage sql`
1030
+
1031
+ Where do the tokens of your Claude Code and codex sessions go, per step of the `dev-workflow`? Needs the extra:
1032
+ `uv tool install 'bmsdna-devtools[usage]'` (DuckDB reads the logs, plotly draws the report; neither is imported by other commands).
1033
+
1034
+ ```bash
1035
+ bdt usage analyze [--scope workflow|all] [--since 2026-10-01] [--price gpt-6-sol=1.25,10,0.125] [--no-codex] [--rebuild]
1036
+ bdt usage sql "select label, round(pct_cost, 1) from v_steps" # ad hoc SQL on the last analysis
1037
+ ```
1038
+
1039
+ It reads `~/.claude/projects` (`--logs`) and `~/.codex/sessions` (`--codex-logs`, skipped if missing), prints a summary and
1040
+ writes `report.html` (charts plus a table view, light/dark) with CSV / Parquet exports next to the database
1041
+ (`~/.cache/bdt/usage/`, or `--db` / `--out`). The report is self-contained and contains your session titles and agent
1042
+ descriptions, so look at it before sharing it.
1043
+
1044
+ - **Steps:** setup, plan, plan review, implement, test, screenshots, design review, quick review, full review, publish / CI.
1045
+ Every model turn is assigned to one by markers in its tool calls: skill calls (`bms-code-review --quick|--full`,
1046
+ `design-review`, `bms-plan-review`; for codex, reading the skill's `SKILL.md`), `bdt` commands, edits (`apply_patch` for
1047
+ codex), pytest / lint, screenshot tooling, image reads. A step stays until the next marker; test runs only count for
1048
+ the turn that runs them. Subagents inherit the step of the turn that spawned them. Only threads that follow the workflow
1049
+ (and what they spawn) are counted, `--scope all` takes every session. It is a heuristic: read differences of a few percent
1050
+ as noise. The rules are SQL in `bmsdna/devtools/usage/sql/03_markers.sql`.
1051
+ - **Numbers:** cost is the API list price (cache writes 1.25x / 2x, cache reads, output, per model), not your actual bill;
1052
+ "% tokens" counts cache reads too, "% fresh tokens" does not. The report also estimates what screenshots cost by sitting in
1053
+ the context of every later turn.
1054
+ - **codex:** a codex run that Claude started with `codex exec` / `codex review` (same directory, within 15 minutes) is counted
1055
+ in that Claude session and step, so reviews run by codex show up under "Review" even though Claude's own log doesn't contain
1056
+ them; codex subagents follow their parent thread. codex models have no built-in price: give one with
1057
+ `--price MODEL=INPUT,OUTPUT,CACHED` (USD per million tokens), repeatable. Turns without a price are counted in tokens
1058
+ but missing from every cost number, and the summary says how many.
1059
+ - **Cache:** parsed records are kept in the database, so the next run only parses new or changed log files (a few seconds).
1060
+ `--rebuild` re-reads everything. Message text is not stored.
1061
+
1041
1062
  ## Releasing
1042
1063
 
1043
1064
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -35,6 +35,7 @@ from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull a
35
35
  from .ado_auth import auth_header
36
36
  from .bdt_config import find_pyproject, load_bdt_table
37
37
  from .cli_tools import CLI_TIMEOUT_SECS, EXIT_WAIT_TIMEOUT, detect_agent_session, require_az, require_gh
38
+ from .usage.cli import usage_app
38
39
  from .gitrepo import (
39
40
  AdoRemote,
40
41
  GitHubRemote,
@@ -103,6 +104,8 @@ issue_app.add_typer(issue_comment_app, name="comment")
103
104
  logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries")
104
105
  app.add_typer(logs_app, name="logs")
105
106
 
107
+ app.add_typer(usage_app, name="usage")
108
+
106
109
  cleanup_app = typer.Typer(
107
110
  name="cleanup",
108
111
  help="Prune merged git worktrees and their orphaned pgdevkit Postgres test databases",
@@ -0,0 +1,5 @@
1
+ """`bdt usage`: where do the tokens of Claude Code / codex sessions go, per step of the dev-workflow?
2
+
3
+ Reads the local session logs with DuckDB, attributes every model turn to a workflow step and prices it.
4
+ The heavy dependencies (duckdb, plotly) are the optional `usage` extra and only imported when a command runs.
5
+ """
@@ -0,0 +1,122 @@
1
+ """`bdt usage`: profile Claude Code / codex session logs per step of the dev-workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from . import pricing
13
+
14
+ usage_app = typer.Typer(
15
+ name="usage",
16
+ help="Where do the tokens of Claude Code / codex sessions go, per step of the dev-workflow? "
17
+ "(needs the `usage` extra: `uv tool install 'bmsdna-devtools\\[usage]'`)",
18
+ )
19
+
20
+ INSTALL_HINT = "bdt usage needs duckdb and plotly: install the extra, e.g. `uv tool install --reinstall 'bmsdna-devtools[usage]'`"
21
+ DEFAULT_CLAUDE_LOGS = "~/.claude/projects"
22
+ DEFAULT_CODEX_LOGS = "~/.codex/sessions"
23
+
24
+
25
+ def default_data_dir() -> Path:
26
+ cache = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache").expanduser()
27
+ return cache / "bdt" / "usage"
28
+
29
+
30
+ def require_duckdb():
31
+ try:
32
+ import duckdb
33
+ except ImportError:
34
+ print(INSTALL_HINT, file=sys.stderr)
35
+ raise typer.Exit(1) from None
36
+ return duckdb
37
+
38
+
39
+ @usage_app.command("analyze")
40
+ def analyze(
41
+ logs: str = typer.Option(DEFAULT_CLAUDE_LOGS, "--logs", help="Claude Code projects directory"),
42
+ codex_logs: str = typer.Option(DEFAULT_CODEX_LOGS, "--codex-logs", help="codex sessions directory (skipped if it does not exist)"),
43
+ no_codex: bool = typer.Option(False, "--no-codex", help="Ignore codex logs"),
44
+ db: str | None = typer.Option(None, "--db", help="DuckDB file (default: <cache dir>/bdt/usage/profile.duckdb). Doubles as the parse cache"),
45
+ out: str | None = typer.Option(None, "--out", help="Directory for report.html and the CSV / Parquet exports (default: next to the database)"),
46
+ scope: str = typer.Option("workflow", "--scope", help="workflow: only threads that follow dev-workflow (and what they spawn); all: every session"),
47
+ since: str = typer.Option("1970-01-01", "--since", help="Only messages from this date on (YYYY-MM-DD)"),
48
+ rebuild: bool = typer.Option(False, "--rebuild", help="Ignore the parse cache in the database and re-read every log file"),
49
+ no_report: bool = typer.Option(False, "--no-report", help="Only print the summary"),
50
+ price: list[str] = typer.Option(
51
+ [], "--price", metavar="MODEL=IN,OUT,CACHED",
52
+ help="USD per million tokens for a model without a built-in price (codex / OpenAI models), repeatable. "
53
+ "Example: --price gpt-6-sol=1.25,10,0.125",
54
+ ),
55
+ ) -> None:
56
+ """Load the session logs, attribute every model turn to a workflow step, price it, and print / render the result.
57
+
58
+ The first run parses every log file; later runs only parse new or changed ones. Costs are API list prices
59
+ (not your actual bill); turns of a model without a price are counted in tokens but not in cost.
60
+ """
61
+ if scope not in ("workflow", "all"):
62
+ raise typer.BadParameter("Must be 'workflow' or 'all'", param_hint="--scope")
63
+ try:
64
+ date.fromisoformat(since)
65
+ except ValueError:
66
+ raise typer.BadParameter("Must be a date like 2026-10-01", param_hint="--since") from None
67
+ try:
68
+ prices = dict(pricing.parse_price(p) for p in price)
69
+ except ValueError as e:
70
+ raise typer.BadParameter(str(e), param_hint="--price") from None
71
+ duckdb = require_duckdb()
72
+ from . import pipeline
73
+
74
+ data_dir = default_data_dir()
75
+ db_path = Path(db).expanduser() if db else data_dir / "profile.duckdb"
76
+ db_path.parent.mkdir(parents=True, exist_ok=True)
77
+ con = duckdb.connect(str(db_path))
78
+ print(f"Reading {logs}" + ("" if no_codex else f" and {codex_logs}") + " ...", file=sys.stderr)
79
+ stats = pipeline.build(
80
+ con, Path(logs), None if no_codex else Path(codex_logs), scope=scope, since=since, rebuild=rebuild, prices=prices
81
+ )
82
+ print(f"{stats.files} log files, {stats.ingested} parsed ({stats.files - stats.ingested} from cache), {stats.removed} removed", file=sys.stderr)
83
+
84
+ summary = con.execute("SELECT * FROM v_summary").fetchone()
85
+ if not summary or not summary[2]:
86
+ print("No sessions in scope. Nothing used dev-workflow in these logs (try --scope all)?", file=sys.stderr)
87
+ raise typer.Exit(1)
88
+ con.sql("SELECT * EXCLUDE (unpriced_msgs, unpriced_models) FROM v_summary").show(max_width=200)
89
+ con.sql("""
90
+ SELECT label AS step, msgs AS turns, sessions, round(cost, 2) AS usd,
91
+ round(pct_cost, 1) AS "% cost", round(pct_tokens, 1) AS "% tokens",
92
+ round(pct_fresh_tokens, 1) AS "% fresh tokens",
93
+ round(pct_of_step_via_subagents) AS "% via subagents"
94
+ FROM v_steps""").show(max_width=200)
95
+ con.sql("SELECT provider, msgs AS turns, tokens, round(cost, 2) AS usd, round(pct_tokens, 1) AS \"% tokens\" FROM v_providers").show(max_width=200)
96
+ unpriced = con.execute("SELECT unpriced_msgs, unpriced_models FROM v_summary").fetchone()
97
+ if unpriced and unpriced[0]:
98
+ print(
99
+ f"Note: {unpriced[0]} turns of {unpriced[1]} have no price, so they are missing from the cost columns. "
100
+ "Add one with --price MODEL=IN,OUT,CACHED (USD per million tokens).",
101
+ file=sys.stderr,
102
+ )
103
+ if not no_report:
104
+ from . import report
105
+
106
+ out_dir = Path(out).expanduser() if out else db_path.parent
107
+ print(f"Report: {report.write_report(con, out_dir)}", file=sys.stderr)
108
+
109
+
110
+ @usage_app.command("sql")
111
+ def sql(
112
+ query: str = typer.Argument(..., help="SQL to run against a database built by `bdt usage analyze` (tables: facts, msgs, threads, v_*)"),
113
+ db: str | None = typer.Option(None, "--db", help="DuckDB file (default: the one `analyze` writes)"),
114
+ ) -> None:
115
+ """Run ad-hoc SQL on the last analysis, e.g. `bdt usage sql "select label, round(pct_cost, 1) from v_steps"`."""
116
+ duckdb = require_duckdb()
117
+ db_path = Path(db).expanduser() if db else default_data_dir() / "profile.duckdb"
118
+ if not db_path.exists():
119
+ print(f"{db_path} does not exist yet: run `bdt usage analyze` first.", file=sys.stderr)
120
+ raise typer.Exit(1)
121
+ con = duckdb.connect(str(db_path), read_only=True)
122
+ con.sql(query).show(max_width=200, max_rows=100)
@@ -0,0 +1,255 @@
1
+ """Build the usage database: load the session logs with DuckDB, assign workflow steps, derive views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from . import pricing
14
+
15
+ if TYPE_CHECKING:
16
+ import duckdb
17
+
18
+ REVIEW_PHASES = {"review_quick", "review_full"}
19
+ KIND_RANK = {"start": 0, "skill": 1, "review_agent": 2, "cmd": 3, "edit": 4, "soft": 5, "once": 6}
20
+
21
+ # Bump when the rec_* tables or their parsing change: caches with another version are rebuilt.
22
+ CACHE_VERSION = "2"
23
+ REC_TABLES = ("rec_assistant", "rec_images", "rec_sessions", "rec_cmds", "rec_threads")
24
+
25
+ # provider -> (ingest script, glob below the log directory)
26
+ PROVIDERS = {
27
+ "claude": ("01_ingest_claude.sql", "*.jsonl"),
28
+ "codex": ("01_ingest_codex.sql", "*.jsonl"),
29
+ }
30
+
31
+
32
+ @dataclass
33
+ class BuildStats:
34
+ files: int
35
+ ingested: int
36
+ removed: int
37
+
38
+
39
+ def _sql(name: str, **subs: str) -> str:
40
+ text = resources.files("bmsdna.devtools.usage").joinpath("sql", name).read_text(encoding="utf-8")
41
+ for key, value in subs.items():
42
+ text = text.replace(f"__{key}__", value)
43
+ return text
44
+
45
+
46
+ def _quote(value: str) -> str:
47
+ return value.replace("'", "''")
48
+
49
+
50
+ def _sql_list(values: list[str]) -> str:
51
+ return "[" + ", ".join(f"'{_quote(v)}'" for v in values) + "]"
52
+
53
+
54
+ def assign_phases(con: duckdb.DuckDBPyConnection, scope: str) -> None:
55
+ """Walk each thread in time order and give every message a workflow phase.
56
+
57
+ A thread that follows the workflow (invoked dev-workflow, or scope='all') starts in 'setup' and
58
+ switches phase on the next marker; the phase stays "sticky" until the next one (test runs are the
59
+ exception: they only count for the turn that runs them). Other spawned threads inherit the phase of
60
+ the message that spawned them and only react to strong markers (review/design/plan skills).
61
+ """
62
+ msgs_by_thread: dict[str, list[tuple[str, datetime]]] = defaultdict(list)
63
+ thread_of_mid: dict[str, str] = {}
64
+ for tid, mid, ts in con.execute("SELECT thread_id, mid, ts FROM msgs ORDER BY thread_id, ts, mid").fetchall():
65
+ msgs_by_thread[tid].append((mid, ts))
66
+ thread_of_mid[mid] = tid
67
+
68
+ markers: dict[str, list[tuple[str, str]]] = defaultdict(list)
69
+ for mid, phase, kind in con.execute("SELECT mid, phase, kind FROM tool_markers ORDER BY tool_use_id").fetchall():
70
+ markers[mid].append((phase, kind))
71
+
72
+ meta = {r[0]: (r[1], r[2] or "") for r in con.execute("SELECT thread_id, parent_mid, agent_type FROM threads").fetchall()}
73
+
74
+ start_ts: dict[str, datetime] = {}
75
+ for tid, ts in con.execute(
76
+ "SELECT m.thread_id, min(m.ts) FROM msgs m JOIN tool_markers k USING (mid) WHERE k.kind = 'start' GROUP BY 1"
77
+ ).fetchall():
78
+ start_ts[tid] = ts
79
+ for tid, ts in con.execute("SELECT thread_id, ts FROM workflow_commands").fetchall():
80
+ if tid in msgs_by_thread:
81
+ start_ts[tid] = min(start_ts.get(tid, ts), ts)
82
+ # Spawned threads that run the whole workflow themselves: told so in the spawn prompt (Claude), or seen doing it.
83
+ for tid, ts in con.execute(
84
+ """
85
+ SELECT th.thread_id, min(m.ts)
86
+ FROM threads th
87
+ JOIN msgs m ON m.thread_id = th.thread_id
88
+ WHERE th.thread_id IN (SELECT m2.thread_id FROM msgs m2 JOIN tool_markers k USING (mid) WHERE k.wf_signal)
89
+ OR th.thread_id IN (
90
+ SELECT am.thread_id
91
+ FROM agent_meta am
92
+ JOIN tool_uses tu ON tu.tool_use_id = am.parent_tool_use_id
93
+ WHERE tu.name IN ('Agent', 'Task')
94
+ AND regexp_matches(lower(json_extract_string(tu.input, '$.prompt')),
95
+ 'follow (the )?[`~/.\\w-]{0,40}dev-workflow|follow the workflow')
96
+ AND coalesce(json_extract_string(tu.input, '$.subagent_type'), '') NOT LIKE '%bms-review-%')
97
+ GROUP BY 1
98
+ """
99
+ ).fetchall():
100
+ start_ts.setdefault(tid, ts)
101
+ if scope == "all":
102
+ for tid, items in msgs_by_thread.items():
103
+ if tid not in meta:
104
+ start_ts[tid] = items[0][1]
105
+
106
+ phase_of: dict[str, str] = {}
107
+ sticky_of: dict[str, str] = {} # the phase a spawned thread inherits
108
+ pending = set(msgs_by_thread)
109
+
110
+ def run_thread(tid: str, inherited: str) -> None:
111
+ agent_type = meta.get(tid, (None, ""))[1]
112
+ if "bms-review-" in agent_type and inherited not in REVIEW_PHASES and inherited != "outside":
113
+ inherited = "review_full"
114
+ phase, started, begin = inherited, False, start_ts.get(tid)
115
+ for mid, ts in msgs_by_thread[tid]:
116
+ if begin is not None and not started and ts >= begin:
117
+ phase, started = "setup", True
118
+ once = None
119
+ for ph, kind in sorted(markers.get(mid, []), key=lambda m: KIND_RANK[m[1]]):
120
+ if kind == "skill" and phase != "outside":
121
+ phase = ph
122
+ elif kind == "review_agent" and phase != "outside" and phase not in REVIEW_PHASES:
123
+ phase = ph
124
+ elif kind in ("cmd", "edit") and started:
125
+ phase = ph
126
+ elif kind == "soft" and started and phase != "design_review":
127
+ phase = ph
128
+ elif kind == "once" and started:
129
+ once = ph
130
+ phase_of[mid] = once or phase
131
+ sticky_of[mid] = phase
132
+
133
+ while pending:
134
+ progressed = False
135
+ for tid in sorted(pending):
136
+ parent_mid = meta.get(tid, (None, None))[0]
137
+ parent_thread = thread_of_mid.get(parent_mid) if parent_mid else None
138
+ if parent_thread in pending and parent_thread != tid:
139
+ continue
140
+ inherited = sticky_of.get(parent_mid, "outside") if parent_mid else "outside"
141
+ run_thread(tid, inherited)
142
+ pending.discard(tid)
143
+ progressed = True
144
+ if not progressed:
145
+ for tid in sorted(pending):
146
+ run_thread(tid, "outside")
147
+ break
148
+
149
+ con.execute("CREATE OR REPLACE TABLE msg_phase (mid VARCHAR, phase VARCHAR)")
150
+ con.execute("INSERT INTO msg_phase SELECT unnest(?), unnest(?)", [list(phase_of), list(phase_of.values())])
151
+
152
+
153
+ def ingest(
154
+ con: duckdb.DuckDBPyConnection, sources: dict[str, Path], rebuild: bool = False
155
+ ) -> BuildStats:
156
+ """Bring the rec_* cache tables up to date, parsing only new or changed log files.
157
+
158
+ `sources` maps a provider ("claude", "codex") to the directory holding its logs; a provider that is not
159
+ listed is left alone (its cached rows stay, they are just not analysed, see `build`).
160
+ """
161
+ con.execute(_sql("00_schema.sql"))
162
+ row = con.execute("SELECT value FROM cache_meta WHERE key = 'version'").fetchone()
163
+ if rebuild or (row and row[0] != CACHE_VERSION):
164
+ for table in (*REC_TABLES, "src_files"):
165
+ con.execute(f"DELETE FROM {table}")
166
+ con.execute("DELETE FROM cache_meta WHERE key = 'version'")
167
+ con.execute("INSERT INTO cache_meta VALUES ('version', ?)", [CACHE_VERSION])
168
+
169
+ files = ingested = removed = 0
170
+ for provider, root in sources.items():
171
+ script, pattern = PROVIDERS[provider]
172
+ on_disk: dict[str, tuple[int, int]] = {}
173
+ for path in root.rglob(pattern):
174
+ st = path.stat()
175
+ on_disk[str(path)] = (st.st_size, st.st_mtime_ns)
176
+ prefix = str(root) + "/"
177
+ known = {
178
+ f: (size, mtime)
179
+ for f, size, mtime in con.execute(
180
+ "SELECT filename, size, mtime_ns FROM src_files WHERE starts_with(filename, ?)", [prefix]
181
+ ).fetchall()
182
+ }
183
+ changed = sorted(f for f, stat in on_disk.items() if known.get(f) != stat)
184
+ gone = sorted(set(known) - set(on_disk))
185
+
186
+ stale = changed + gone
187
+ if stale:
188
+ con.execute("CREATE OR REPLACE TEMP TABLE stale AS SELECT unnest(?) AS filename", [stale])
189
+ for table in (*REC_TABLES, "src_files"):
190
+ con.execute(f"DELETE FROM {table} WHERE filename IN (SELECT filename FROM stale)")
191
+ if changed:
192
+ con.execute(_sql(script, FILES=_sql_list(changed)))
193
+ con.execute(
194
+ "INSERT INTO src_files SELECT unnest(?), unnest(?), unnest(?)",
195
+ [changed, [on_disk[f][0] for f in changed], [on_disk[f][1] for f in changed]],
196
+ )
197
+ files += len(on_disk)
198
+ ingested += len(changed)
199
+ removed += len(gone)
200
+ return BuildStats(files=files, ingested=ingested, removed=removed)
201
+
202
+
203
+ _META_COLUMNS = {"agentType": "VARCHAR", "description": "VARCHAR", "toolUseId": "VARCHAR",
204
+ "spawnDepth": "INTEGER", "model": "VARCHAR"}
205
+
206
+
207
+ def _meta_source(claude_dir: Path | None) -> str:
208
+ """FROM-clause for the Claude subagent meta files (an empty table if there are none: read_json errors on no match)."""
209
+ cols = ", ".join(f"{k}: '{v}'" for k, v in _META_COLUMNS.items())
210
+ files = sorted(str(p) for p in claude_dir.rglob("subagents/*.meta.json")) if claude_dir else []
211
+ if files:
212
+ return (f"read_json({_sql_list(files)}, union_by_name = true, ignore_errors = true, filename = true, "
213
+ f"columns = {{{cols}}})")
214
+ empty = ", ".join(f"NULL::{v} AS {k}" for k, v in _META_COLUMNS.items())
215
+ return f"(SELECT NULL::VARCHAR AS filename, {empty} WHERE false)"
216
+
217
+
218
+ def build(
219
+ con: duckdb.DuckDBPyConnection,
220
+ claude_dir: Path | None,
221
+ codex_dir: Path | None = None,
222
+ scope: str = "workflow",
223
+ since: str = "1970-01-01",
224
+ rebuild: bool = False,
225
+ prices: dict[str, pricing.Price] | None = None,
226
+ ) -> BuildStats:
227
+ """Ingest the given log directories (None = skip that provider) and derive everything from the cache."""
228
+ sources = {
229
+ provider: directory.expanduser()
230
+ for provider, directory in (("claude", claude_dir), ("codex", codex_dir))
231
+ if directory is not None and directory.expanduser().is_dir()
232
+ }
233
+ # The JSON reader keeps a buffer of `maximum_object_size` (16 MB in the ingest scripts) per thread: on a busy,
234
+ # many-core machine that is gigabytes for no gain, since the log files are read in a few seconds anyway.
235
+ con.execute(f"SET threads = {min(os.cpu_count() or 4, 8)}")
236
+ stats = ingest(con, sources, rebuild=rebuild)
237
+ con.execute(_sql(
238
+ "02_messages.sql",
239
+ META_SOURCE=_meta_source(sources.get("claude")),
240
+ SINCE=_quote(since),
241
+ PROVIDERS=", ".join(f"'{p}'" for p in sources) or "''",
242
+ ))
243
+ con.execute(_sql("03_markers.sql"))
244
+ assign_phases(con, scope)
245
+ rows = ", ".join(f"('{_quote(m)}', {i}, {o}, {c})" for m, i, o, c in pricing.price_rows(prices))
246
+ con.execute(_sql(
247
+ "04_facts.sql",
248
+ SINCE=_quote(since),
249
+ PRICE_ROWS=rows,
250
+ FALLBACK_MODEL=pricing.FALLBACK_MODEL,
251
+ CW5=str(pricing.CACHE_WRITE_5M_FACTOR),
252
+ CW1H=str(pricing.CACHE_WRITE_1H_FACTOR),
253
+ ))
254
+ con.execute(_sql("05_views.sql"))
255
+ return stats
@@ -0,0 +1,59 @@
1
+ """USD per million tokens. Edit freely, or pass `--price`. An unknown Claude model falls back to FALLBACK_MODEL;
2
+ any other model (codex / OpenAI) has no built-in price and stays unpriced until you give it one.
3
+
4
+ Cache writes are priced relative to input: 1.25x for the 5 minute TTL, 2x for the 1 hour TTL.
5
+ Prices are API list prices, so the result is an "API-equivalent cost" (useful for relative
6
+ shares even if you are on a flat subscription).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ CACHE_WRITE_5M_FACTOR = 1.25
14
+ CACHE_WRITE_1H_FACTOR = 2.0
15
+ FALLBACK_MODEL = "claude-sonnet-5-5"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Price:
20
+ input: float
21
+ output: float
22
+ cache_read: float
23
+
24
+
25
+ PRICES: dict[str, Price] = {
26
+ "claude-fable-5-1": Price(10.0, 50.0, 0.25),
27
+ "claude-fable-5": Price(10.0, 50.0, 1.0),
28
+ "claude-opus-5-5": Price(4.0, 20.0, 0.20),
29
+ "claude-opus-5": Price(5.0, 25.0, 0.50),
30
+ "claude-opus-4-8": Price(5.0, 25.0, 0.50),
31
+ "claude-opus-4-7": Price(5.0, 25.0, 0.50),
32
+ "claude-opus-4-6": Price(5.0, 25.0, 0.50),
33
+ "claude-sonnet-5-5": Price(2.0, 10.0, 0.20),
34
+ "claude-sonnet-5": Price(2.0, 10.0, 0.20),
35
+ "claude-sonnet-4-6": Price(3.0, 15.0, 0.30),
36
+ "claude-haiku-4-5": Price(1.0, 5.0, 0.10),
37
+ "claude-haiku-4-5-20251001": Price(1.0, 5.0, 0.10),
38
+ "<synthetic>": Price(0.0, 0.0, 0.0),
39
+ }
40
+
41
+
42
+ def parse_price(option: str) -> tuple[str, Price]:
43
+ """`--price MODEL=INPUT,OUTPUT,CACHE_READ` (USD per million tokens), e.g. `gpt-6-sol=1.25,10,0.125`."""
44
+ model, sep, rest = option.partition("=")
45
+ parts = rest.split(",")
46
+ try:
47
+ if not (sep and model.strip() and len(parts) == 3):
48
+ raise ValueError
49
+ inp, out, cached = (float(x) for x in parts)
50
+ except ValueError:
51
+ raise ValueError(f"Expected MODEL=INPUT,OUTPUT,CACHE_READ (USD per million tokens), got {option!r}") from None
52
+ if min(inp, out, cached) < 0:
53
+ raise ValueError(f"Prices must not be negative: {option!r}")
54
+ return model.strip(), Price(inp, out, cached)
55
+
56
+
57
+ def price_rows(extra: dict[str, Price] | None = None) -> list[tuple[str, float, float, float]]:
58
+ """Rows for the `pricing` table: the built-in prices, overridden / extended by `extra`."""
59
+ return [(m, p.input, p.output, p.cache_read) for m, p in {**PRICES, **(extra or {})}.items()]