llm_cost_tracker 0.7.3 → 0.9.0

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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6950dae400eac9294a57a0ba2fd2bce7977658837962eafffc3836fa4ab9bd2b
4
- data.tar.gz: c398f5271d3d0fa53cb27e1206e418e0242fb9dff73ed2c405903b92dfaf8a48
3
+ metadata.gz: f17f618b28473afa871c9961a443a34152f4de81f7026d676d62b7e2bd1396d8
4
+ data.tar.gz: d024b23f0ca6cd117afa5d10faa0a0b96374391a4741ea330b924b5091f665f7
5
5
  SHA512:
6
- metadata.gz: c52638e31e7eb0f46308312339bd40cfce87227a8c7ec77c94b3af08ffc931c3cffb9566f2ce15ec70a87700084e5e9bb6d05fe670028b57a12066af4a9ebaf6
7
- data.tar.gz: 12da45f4cd8c485bd6fde5f9376bdfa2c8e618abd41e7be11c022878ec005348ca5d98578216170f1b7105dcc9e2c0c4b037cb5394e2085e2526e04ee8d5a885
6
+ metadata.gz: 0abf684c595b7bc84dfda26ffc62eaabc0c6d91d0b93f1065bf6e824c7867326b7978875d845d3df8be25bfa04ff9091150e0a4cac7f84d835ceaf2f1e2996bb
7
+ data.tar.gz: 5b9405bf332b2e9e1eae05f0e7d107d4bb76ea71a6602846a16198540f3e0f315f48dbb316bbda24812d4d66c56ba837a42bfe81aeea502238f09e1f0202c6b4
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.5
data/CHANGELOG.md CHANGED
@@ -4,6 +4,179 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.0] - 2026-05-12
8
+
9
+ 0.9 leans the default install: only `calls`, `call_line_items`, and `call_tags`
10
+ are mandatory. Durable ingestion, rollup-cached budget reads, and provider
11
+ invoice reconciliation are opt-in behind config flags and dedicated generators.
12
+ Plus expanded SDK capture (OpenAI embeddings/audio/images/moderation, RubyLLM
13
+ paint/moderate), correct handling of Anthropic data residency and Priority
14
+ Tier, and a security-hardened dashboard. Existing installs need a migration —
15
+ see [Upgrading](docs/upgrading.md).
16
+
17
+ ### Added
18
+
19
+ - **Experimental:** opt-in provider invoice reconciliation. Set `config.reconciliation_enabled = true` and run `bin/rails generate llm_cost_tracker:reconciliation`. Public surface: `LlmCostTracker::Reconciliation.import / .diff`, `config.register_reconciliation_importer(:source) { … }`, rake tasks `llm_cost_tracker:reconcile:import` and `:reconcile:diff`. Doctor warns when drift exceeds 5% or imports go stale past 14 days. See [Configuration](docs/configuration.md#reconciliation-experimental-opt-in).
20
+ - Dashboard Data Quality page now shows a "Streaming health by provider" breakdown (streams, with-usage, unknown, unknown share) so a misconfigured OpenAI-compatible host shipping streams without `stream_options.include_usage` is visible at a glance.
21
+ - Dashboard tag detail page drills into a single value via `?tag_value=…` with total cost, call count, average per call, and a daily spend timeseries.
22
+ - Bundled rates for OpenAI embeddings (`text-embedding-3-small` / `-3-large` / `-ada-002`, including 50% batch discount) and token-priced transcription (`gpt-4o-transcribe`, `gpt-4o-mini-transcribe`). Token-priced transcription splits audio and text inputs at their separate rates. DALL-E and Whisper still record as zero-token visibility events until their per-image / per-minute pricing components land.
23
+ - OpenAI `gpt-image-1` / `gpt-image-1-mini` / `gpt-image-1.5` / `gpt-image-2` priced per image-token at their published standard rates, with `batch_*` shadow rates for the 50% batch tier. (Earlier preview snapshots stored only the batch rates, which silently halved image-generation costs.) The SDK integration extracts `usage.input_tokens_details.image_tokens` for image-as-input flows (edits / variations) and treats `usage.output_tokens` as image output. Requires the new `bin/rails generate llm_cost_tracker:upgrade_image_tokens` migration on v0.8 → v0.9 upgrades.
24
+ - OpenAI `tts-1` / `tts-1-hd` priced per character (request `input.length`). `gpt-4o-mini-tts` is left as a zero-cost visibility event because its tokens are not exposed to the client.
25
+ - OpenAI SDK integration now also patches `Embeddings#create`, `Images#generate` / `#edit` / `#create_variation`, `Audio::Transcriptions#create`, `Audio::Speech#create`, `Moderations#create`, and `Chat::Completions#stream`. Calls without provider-reported usage record as zero-token visibility events.
26
+ - RubyLLM SDK integration also records `Provider#paint` and `Provider#moderate`.
27
+ - `bin/rails generate llm_cost_tracker:upgrade_call_rollups_provider` writes the v0.8 → v0.9 migration that adds the `provider` column and swaps the unique index. Re-runs are no-ops.
28
+ - [EU AI Act record-keeping guide](docs/eu_ai_act.md) — maps the ledger fields and `llm_cost_tracker:prune` retention to Article 26(6) deployer obligations (≥ 6-month retention, traceability, attribution tags). Not legal advice.
29
+
30
+ ### Fixed
31
+
32
+ - Subscriber failures during `Tracker.record` no longer lose the event — the ledger write happens first; subscriber errors are caught and logged.
33
+ - Header `total_cost` no longer mixes currencies. Mismatched service-line costs keep their per-line currency and are excluded from the header total (with a warning).
34
+ - Budget reads aggregate across all rollup currencies instead of being silently scoped to USD only.
35
+ - `bin/rails llm_cost_tracker:setup` no longer fails with `Missing Thor class for invoke llm_cost_tracker:prices`, is idempotent on re-runs, and surfaces a friendly error when the database is unreachable.
36
+ - Stream events are no longer lost when finalization raises. The collector retries on the next `finish!`. Abandoned streams (wrapped but never iterated) emit a usage event instead of disappearing.
37
+ - Faraday streaming overflow keeps the buffer accumulated up to the limit (matching the SDK collector) instead of dropping all events.
38
+ - Edits to `config.prices_file` are picked up without a gem reload — the lookup cache invalidates on file mtime changes.
39
+ - Models flagged with `_source: "manual"` in the local prices file are preserved through `prices:refresh` when the remote registry does not claim the same key.
40
+ - Anthropic Priority Tier no longer falls to `cost_status: unknown`. It's a throughput commitment, not a per-token surcharge — both the SDK integration and the Faraday parser treat `service_tier: "priority"` as standard pricing.
41
+ - Anthropic `data_residency` mode triggers on `inference_geo: "us"` only — the documented +1.1x uplift tier. Earlier preview ranges that listed `"eu"` were incorrect; EU data residency runs through Bedrock Frankfurt or Vertex Belgium with separate pricing, not the Anthropic API.
42
+ - Anthropic `web_fetch_request` is recorded with a `$0` rate (Anthropic bills web fetch via standard tokens, not per fetch). The scraper picks up the "no additional charges" wording so `prices:refresh` keeps it accurate.
43
+ - OpenAI `web_search_call` is now priced model-aware. The legacy `web_search_preview` tool routes to `web_search_preview_request_reasoning` ($10/1k for gpt-5/o-series) or `web_search_preview_request_non_reasoning` ($25/1k for everything else), matching OpenAI's three published web-search billing paths. `gpt-5-chat-latest` and dotted variants (`gpt-5.1-chat-latest`, `gpt-5.2-chat-latest`, …) are classified as non-reasoning despite the `gpt-5` prefix.
44
+ - Anthropic Cost API reconciliation now ingests rows against live admin payloads (preview builds expected obsolete field names and produced zero rows). `service_tier: "batch"` and `inference_geo: "us"` are the only dimensions that promote a row's `pricing_mode`.
45
+ - Reconciliation diff windows are anchored in UTC; non-UTC servers no longer skew the window.
46
+ - Reconciliation provider totals sum only invoices fully contained in the diff window. Partially overlapping invoices no longer count at their full `billed_amount`.
47
+ - Reconciliation diff window upper bound is now exclusive of midnight on the day after `period_end`. Calls tracked at exactly `00:00:00.000` of the next month no longer get counted in both periods.
48
+ - `Reconciliation.import` / `Reconciliation.diff` accept (and require, for unmapped sources) an explicit `provider:`. Built-in mappings cover `openai`, `openai_usage`, `anthropic`, `anthropic_usage`, `gemini`. CSV and other custom sources must pass `provider:` (or be derivable from a prior import's metadata) — the previous silent fall-through summed local calls across every provider.
49
+ - Reconciliation import errors no longer echo the exception verbatim into the dashboard flash. The full trace goes to logs; the alert shows the exception class only.
50
+ - `Reconciliation::Importer` works on MySQL/Trilogy installs (adapter-aware `upsert_all`).
51
+ - Reconciliation `external_id` is namespaced by `source/provider` for sources that carry multiple providers (e.g. `csv/openai:row-1` vs `csv/anthropic:row-1`). The same CSV row id imported under two providers no longer collides on the unique index. Native sources keep their `openai:` / `anthropic:` / `gemini:` prefix.
52
+ - Reconciliation dashboard groups latest-period and drill-down by source, provider, and currency. A second provider importing under the same source no longer hides its drift in the first provider's row.
53
+ - OpenAI Cost API reconciliation tags the organization id under `provider_workspace_id` so org-level scope filters work.
54
+ - Reconciliation diff drill-down is capped at the top 100 unmatched rows by amount with totals counted separately, so the dashboard stays responsive on large monthly reconciliations. Pass `DRILLDOWN_LIMIT=all` to `rake llm_cost_tracker:reconcile:diff` to see every row.
55
+ - Period totals fall back to live aggregation from `llm_cost_tracker_calls` when `cache_rollups = true` but the rollups table has no row for the period. Budget reads and dashboard totals no longer read zero during a rollup rebuild window after the v0.9 upgrade migration.
56
+ - OpenAI hosted-tool service line items (`web_search_call`, `file_search_call`, `code_interpreter_call`, `mcp_call`) are recorded when the SDK returns the type as a Symbol. Previously these line items were silently dropped on SDK-shaped responses.
57
+ - Image generation streams (`gpt-image-1.5`, `gpt-image-2`) and audio streams no longer overflow on a single base64 chunk; the final usage event is captured and tokens get priced.
58
+ - Interrupted Anthropic and Gemini streams record the right provider name instead of `provider: "unknown"`.
59
+ - Tag sanitizer redacts secrets before truncating, so the leading bytes of a secret can't survive a small `max_tag_value_bytesize`. Nested `[REDACTED]` markers stay whole regardless of the byte budget.
60
+ - `Pricing::Registry` rejects non-finite price values (`Infinity` / `NaN`) alongside negatives.
61
+ - Reconciliation `ProviderInvoiceImport.started_at` is the wall-clock import time. Backfills with a historical `imported_at` no longer invert `resume_cursor_for` ordering.
62
+ - Reconciliation install migration is re-runnable on installs that already carry the v0.8 placeholder tables.
63
+ - Pre-release v0.9 deployers who imported reconciliation rows before these fixes need `LlmCostTracker::ProviderInvoice.delete_all` and a re-import — the `external_id` prefix and the OpenAI organization-id field both changed shape.
64
+ - Budget reads survive the v0.9 upgrade migration's rollup truncation — a partial rollup row no longer hides historical pre-migration spend in the same period.
65
+ - Streaming requests that hit `unknown_pricing_behavior = :raise` after the response is received raise without recording a synthetic zero-token event.
66
+ - Reconciliation doctor checks each `source / provider / currency` combination separately; a stale Anthropic CSV import no longer hides behind a fresh OpenAI one on the same source.
67
+ - Reconciliation imports normalise `currency` to upper case so `usd` and `USD` no longer split the diff.
68
+ - Reconciliation dashboard and CLI render `n/a` for invoice rows imported with no `billed_amount` instead of `$0.00`.
69
+ - Reconciliation diff drill-down shows the actual unmatched rows even when most invoices match — small-amount unmatched rows are no longer hidden by a wall of matched big-amount rows.
70
+ - OpenAI SDK Responses calls bill image and text tokens separately for `gpt-image-*` models, matching the Faraday parser.
71
+ - OpenAI SDK integration captures the request when the caller passes a typed request object (anything that responds to `to_h`) instead of dropping it.
72
+ - Custom prices files with `Infinity` / `NaN` service-charge rates fail to load with a clear error instead of silently corrupting cost math.
73
+ - High-cardinality tag filters (`Call.by_tag(:tenant_id, …)`) now hit a composite index instead of scanning. Existing installs run `bin/rails generate llm_cost_tracker:upgrade_call_tags_key_value_index && bin/rails db:migrate`.
74
+ - Reconciliation diff over a large invoice set uses an index scan on the new `(source, currency, period_start)` composite.
75
+ - Doctor warns when provider invoice rows are stored with non-uppercase currency and points at the one-line backfill SQL, instead of the dashboard silently zeroing out diffs against legacy lowercase data.
76
+ - A request-level `pricing_mode` no longer overrides what the provider reports back on a streamed response. Provider-reported standard wins over a request that asked for priority.
77
+ - The new generators (`call_rollups`, `durable_ingestion`, `reconciliation`, `upgrade_call_rollups_provider`) are reachable through `bin/rails generate llm_cost_tracker:<name>`.
78
+ - Faraday streaming captures no longer silently degrade to `usage_source: :unknown`.
79
+ - Dashboard filters apply the default 30-day range when `from`/`to` params are missing.
80
+ - `provider_api_key_id` and `provider_workspace_id` are masked on the call detail page and CSV export. Host apps that added a `metadata` column written as a JSON string now flow through the same masking instead of rendering the raw column.
81
+ - Faraday parser tracks OpenAI `/v1/images/*` and `/v1/audio/transcriptions`/`/v1/audio/translations` so raw-Faraday image generations and transcriptions land in the ledger. `/v1/audio/speech` and `/v1/moderations` are also matched so `Tracker.enforce_budget!` gates them; they do not record a row because OpenAI does not return token usage for those endpoints.
82
+ - OpenAI SDK `Audio::Translations#create` is now patched alongside `Audio::Transcriptions#create`.
83
+ - OpenAI SDK `Images#generate` / `#edit` / `#create_variation` no longer double-counts cached input tokens.
84
+ - OpenAI SDK `Responses.create` and Faraday parser both route output to `image_output_tokens` for `gpt-image-*` models even when the response omits `output_tokens_details.image_tokens`.
85
+ - OpenAI SDK `Images#generate` / `#edit` / `#create_variation` no longer drops the text-output remainder when `output_tokens_details` reports only `image_tokens`. The remainder lands as `output_tokens`, matching the Faraday parser.
86
+ - RubyLLM `Provider#paint` for `gpt-image-*` models records image output tokens under `image_output_tokens` so image rates apply.
87
+ - RubyLLM integration treats Anthropic `service_tier: "priority"` as standard pricing (Priority Tier is committed throughput, not a surcharge). Previously these calls fell to `cost_status: unknown` because the literal `"priority"` was passed through as `pricing_mode`.
88
+ - Reconciliation diff falls back to live `llm_cost_tracker_call_line_items` aggregation when the rollup fast path finds no row for the period. Without the fallback, past-month diffs after the v0.9 `upgrade_call_rollups_provider` migration (which truncates rollups) would report `local_total = $0` until events repopulate the new schema.
89
+ - Provider-invoice reconciliation falls back to `match_basis: "model"` (was `period_only`) when an invoice carries only a model identifier.
90
+ - `prices:refresh` bootstraps a missing local pricing file instead of failing with `Errno::ENOENT`.
91
+ - Doctor's durable-inbox verification no longer leaves a synthetic inbox row behind when `Tracker.track` raises `BudgetExceededError`.
92
+ - Install-generator snippet in [Upgrading](docs/upgrading.md) for the reconciliation table now matches the shipped index (`(source, currency, period_start)`).
93
+ - Doctor catches schema drift on required columns, required indexes, and the foreign key on `call_line_items` before the first row is inserted.
94
+ - Service-charge rows render `n/a` instead of `$0.00` when `cost_status` is `unknown`, so unpriced charges don't masquerade as zero-cost.
95
+ - Enabling `:ruby_llm` together with `:openai` / `:anthropic` logs a warning at install — RubyLLM routes through HTTP, so calls would otherwise be double-counted. Pick one path per provider.
96
+
97
+ ### Changed
98
+
99
+ - BREAKING: `bin/rails generate llm_cost_tracker:install --dashboard` no longer writes the `mount LlmCostTracker::Engine` line into `config/routes.rb`. The CLI prints the snippet wrapped in your auth instead — leaving the dashboard auto-mounted would expose spend, tags, and provider IDs to anyone who can reach the host. Add the route under your authentication block.
100
+ - BREAKING: `config.durable_ingestion` defaults to `false`. Tracking writes go directly to the ledger from the request thread; the durable inbox + worker + leases tables are no longer created by the install generator. Existing installs keep their tables — set `config.durable_ingestion = true` to keep the inbox path. Fresh installs that need durability run `bin/rails generate llm_cost_tracker:durable_ingestion` and flip the flag.
101
+ - BREAKING: `config.cache_rollups` defaults to `false`. Budget reads aggregate live from `llm_cost_tracker_calls`; the rollup table is no longer created by the install generator. Existing installs keep their table — set `config.cache_rollups = true` to keep the rollup fast path. Fresh installs run `bin/rails generate llm_cost_tracker:call_rollups` and flip the flag.
102
+ - BREAKING: `llm_cost_tracker_call_rollups` gains a `provider` column; unique index moves from `(period, period_start, currency)` to `(period, period_start, currency, provider)`. See [Upgrading](docs/upgrading.md).
103
+ - BREAKING: `llm_cost_tracker_calls` gains `image_input_tokens` and `image_output_tokens` columns (default 0) so OpenAI `gpt-image-*` models can bill image-token rates separately from text. Run `bin/rails generate llm_cost_tracker:upgrade_image_tokens && bin/rails db:migrate`. CSV exports include the new columns between `audio_input_tokens` / `output_tokens` and `audio_output_tokens` / `total_tokens` respectively — downstream consumers indexing by header name keep working; positional consumers shift by two.
104
+ - BREAKING: `LlmCostTracker::Call.by_tag(key, value)` encodes Hash and Array values with `JSON.generate` to match how `Ledger::Store` writes them. The previous `value.to_s` path produced `"{:k=>v}"`-shaped strings that never matched stored JSON, so filtering by nested attribution silently returned zero rows.
105
+ - Faraday middleware auto-injects `stream_options: { include_usage: true }` on OpenAI and OpenAI-compatible chat-completions streaming requests when the caller hasn't set it. Disable with `config.auto_enable_stream_usage = false`.
106
+ - Header `total_cost` and per-line-item rates can no longer disagree on the context-tier boundary or the resolved pricing mode.
107
+ - OpenAI-compatible chat-completions streams without a final usage chunk log a warning instead of recording silently as `usage_source: "unknown"`.
108
+ - `Tags::Sanitizer` redacts tag values matching known secret patterns (OpenAI/Anthropic, GitHub, AWS, JWT, Slack, Stripe, Google API key, `Bearer …`) regardless of tag key, recurses into nested Hash/Array leaves, and on tag-count overflow keeps the most recently added tags. `Tags::Context` sanitises at block entry so raw values never reach notification subscribers, the Faraday request env, or in-flight stream collectors.
109
+ - Engine dashboard adds CSRF protection on the reconciliation import endpoint, sets `Cache-Control: no-store` on CSV exports, registers `tag` / `tag_value` in `config.filter_parameters`, and emits `X-Frame-Options: DENY` / `Referrer-Policy: same-origin` / a baseline `Content-Security-Policy` on every dashboard response. CSV export is capped at 10,000 rows and respects the requested sort.
110
+ - Dashboard schema drift check runs once per process instead of on every request, cutting per-request DB metadata load. Code reloads in development still trigger a re-check.
111
+ - Dashboard dynamic widths (progress bars, budget fills, stack segments) render via a per-request CSP-nonced `<style>` block instead of inline `style="…"` attributes. Strict `style-src 'self' 'nonce-…'` no longer collapses the visualisations.
112
+
113
+ ## [0.8.0] - 2026-05-07
114
+
115
+ 0.8 is a storage rebuild. Tokens and tool/runtime charges share one shape
116
+ (`Billing::LineItem`) and live in a dedicated line items table. Per-component
117
+ cost columns and the standalone service charges table are gone. Several tables
118
+ were also renamed during the cycle. See [Upgrading](docs/upgrading.md) for the
119
+ migration path — there is no rolling-deploy upgrade.
120
+
121
+ ### Added
122
+
123
+ - `llm_cost_tracker_call_line_items` — one row per priced component (text/audio/cached tokens, web search, code execution, grounding, container sessions, file search). Tokens and tool charges share one shape and one `cost_status` semantics.
124
+ - `llm_cost_tracker_call_tags` — normalized attribution. Tag filters and aggregations now JOIN through this table on PostgreSQL and MySQL alike.
125
+ - `llm_cost_tracker_provider_invoices` — placeholder table reserved for v0.9 invoice reconciliation.
126
+ - `Billing::LineItem` value object covering both token and service charges. `LineItem.from_token_usage` and explicit `component_key:` builders price token and tool/runtime quantities through the same path.
127
+ - `Pricing.price_line_items` — single pricing pass for token + tool/runtime line items, used by `Tracker.build_event`.
128
+ - Doctor schema checks for `llm_cost_tracker_call_line_items`, `llm_cost_tracker_call_tags`, and `llm_cost_tracker_provider_invoices`.
129
+ - Doctor sample-based drift checks: header `total_cost` vs `SUM(line_items.cost)` and stored line item cost vs `pricing_snapshot.rates` (RFC §Doctor).
130
+ - `currency` column on `llm_cost_tracker_call_rollups` (default `USD`) with a `(period, period_start, currency)` unique index. v0.8 stays single-currency; the schema is in place so v0.9 multi-currency rollups don't need another migration.
131
+ - `Billing::Components::REGISTRY` now loads from `lib/llm_cost_tracker/billing/components.yml`. Adding a billable component is one YAML row plus a price entry — no more 11-line `Component.new(...)` literals.
132
+ - Anthropic web search and code execution usage emitted as line items with `component_key: :web_search_request` / `:code_execution_request`. SDK integration emits the same line items from native SDK responses, not just Faraday-wrapped ones.
133
+ - OpenAI hosted web search, file search, and Code Interpreter container sessions emitted as line items via both Faraday and SDK integration paths.
134
+ - Gemini grounding queries emitted as line items.
135
+ - `provider_project_id`, `provider_api_key_id`, `provider_workspace_id`, `batch` capture dimensions on `LlmCostTracker.track` and the `Event` payload, persisted as columns on `llm_cost_tracker_calls`.
136
+ - `Pricing::EffectivePrices` permutes compound pricing modes (e.g. `priority_batch_data_residency`) when matching rates, so combined modes resolve correctly.
137
+ - `Pricing::Sync` registry-diff compares `service_charges` rates in addition to model rates.
138
+ - Dashboard polish pass: shared `_filters.html.erb` and `_sort.html.erb` partials, sticky table headers, button hover/active states, spacing/shadow scales, and a full `prefers-color-scheme` dark palette.
139
+ - Bundled audio and tool rates refreshed from current provider pricing.
140
+
141
+ ### Changed
142
+
143
+ - BREAKING: Renamed `llm_api_calls` → `llm_cost_tracker_calls`, `llm_cost_tracker_period_totals` → `llm_cost_tracker_call_rollups`, `llm_cost_tracker_inbox_events` → `llm_cost_tracker_ingestion_inbox_entries`, `llm_cost_tracker_ingestor_leases` → `llm_cost_tracker_ingestion_leases`. Corresponding model `LlmCostTracker::PeriodTotal` renamed to `LlmCostTracker::CallRollup`; ingestion models live under `LlmCostTracker::Ingestion::InboxEntry` and `LlmCostTracker::Ingestion::Lease`.
144
+ - BREAKING: Per-component cost columns removed from `llm_cost_tracker_calls` (`input_cost`, `output_cost`, `cache_read_input_cost`, `cache_write_input_cost`, `cache_write_extended_input_cost`, `cache_write_1h_input_cost`, `audio_input_cost`, `audio_output_cost`). The header keeps `total_cost` only; per-component costs live in line items.
145
+ - BREAKING: `llm_cost_tracker_calls.tags` JSONB column removed in favor of `llm_cost_tracker_call_tags`. `Call#parsed_tags`, `Call.by_tags`, `Call.cost_by_tag`, `Call.group_by_tag`, and the dashboard tag explorer now read the normalized table.
146
+ - BREAKING: `llm_cost_tracker_service_charges` table removed. Tool/runtime rows are stored in `llm_cost_tracker_call_line_items` with `unit != 'token'`.
147
+ - BREAKING: `Billing::ServiceCharge` value object and `LlmCostTracker::ServiceCharge` AR model removed. Use `Billing::LineItem` and `LlmCostTracker::CallLineItem`.
148
+ - BREAKING: `Event#service_charges` removed. Filter `event.line_items` by `unit != :token` instead.
149
+ - BREAKING: `Call#service_charges` association removed. Use `call.line_items.where.not(unit: "token")`.
150
+ - BREAKING: `LlmCostTracker.track(service_charges:)` keyword renamed to `service_line_items:`. Hash keys: `component:` → `component_key:`, `source_key:` → `provider_field:`, `pricing_basis: PROVIDER_USAGE_BASIS` → `pricing_basis: :provider_usage`.
151
+ - BREAKING: `Billing::CostStatus.call(service_charges:)` keyword renamed to `service_line_items:`.
152
+ - BREAKING: `Pricing.cost_with_service_charges` public API removed; replaced internally by `Pricing.price_line_items`.
153
+ - BREAKING: Top-level delegators `LlmCostTracker.flush!`, `LlmCostTracker.shutdown!`, `LlmCostTracker.enforce_budget!` removed. Use `LlmCostTracker::Ingestion::Worker.flush!` / `.shutdown!` directly; budget enforcement is internal.
154
+ - BREAKING: `LlmCostTracker.track` requires explicit `tokens:` and accepts `tags:` as a hash; the previous keyword shape is no longer supported.
155
+ - BREAKING: Notification payload (`llm_request.llm_cost_tracker`) no longer carries `service_charges`. Subscribers read `line_items`.
156
+ - BREAKING: Inbox payload v0/v1 compatibility dropped; only v2 is accepted. Drain any pre-v2 entries on the prior gem version before bumping.
157
+ - BREAKING: Ruby 3.4+ required.
158
+ - BREAKING: Legacy upgrade generators removed (`add_billing`, `add_ingestion`, `add_call_rollups`, `add_capture_dimensions`, `add_latency_ms`, `add_provider_response_id`, `add_streaming`, `add_token_usage`, `upgrade_cost_precision`, `upgrade_schema_foundation`, `upgrade_tags_to_jsonb`). Doctor no longer suggests them.
159
+ - `llm_cost_tracker_call_tags.value` widened to TEXT (was VARCHAR), and the `[:key, :value]` composite index dropped in favor of `:key` only — value-equality filters scan the per-key bucket.
160
+ - `Configuration#pricing_overrides` validates shape at assignment time rather than at first read.
161
+ - Pricing computes a partial `total_cost` (with `cost_status: :partial`) when only some token components have rates; previously `total_cost` was nil whenever any component lacked a rate.
162
+ - `TokenUsage.build` clamps negative token counts to zero so anomalous provider payloads don't poison rollups.
163
+ - Stream collector buffer overflow keeps already-accumulated events instead of dropping them.
164
+ - Budget guardrail preflight time is excluded from SDK call latency measurements.
165
+ - Dashboard data-quality breakdown computes per-component cost from line items via JOIN; usage_rows accepts `component_costs:` hash.
166
+ - CSV export pulls tag JSON from `tag_records` instead of the dropped JSONB column.
167
+ - The fingerprinted dashboard stylesheet is served with `Cache-Control: no-store` in development so edits show up without a hard reload; production keeps the immutable cache.
168
+
169
+ ### Fixed
170
+
171
+ - Railtie no longer requires removed legacy upgrade generators at boot, so installs on a clean app don't crash during eager-load.
172
+ - `Tracker` only flags unknown pricing when token quantities are positive — service-only events with zero tokens no longer raise `Pricing::Unknown`.
173
+ - `Billing::CostStatus.cost_status_for` coerces symbol/string status values consistently when building line items.
174
+ - Gemini `thoughtsTokenCount` is billed at the output token rate (already present in 0.7.3, kept for clarity given the rebuild).
175
+
176
+ ### Removed
177
+
178
+ - Dead `Billing::LineItem.from_service_charge` constructor and the unused `Call.with_json_tags` scope.
179
+
7
180
  ## [0.7.3] - 2026-05-01
8
181
 
9
182
  ### Fixed
data/README.md CHANGED
@@ -1,50 +1,45 @@
1
1
  # LLM Cost Tracker
2
2
 
3
- A Rails-native ledger for estimating LLM API spend.
3
+ Self-hosted LLM cost tracking for Rails.
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
6
6
  [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions)
7
7
  [![codecov](https://codecov.io/gh/sergey-homenko/llm_cost_tracker/branch/main/graph/badge.svg)](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
8
8
 
9
- If someone keeps asking "where did that LLM bill come from?", this gem records provider-reported usage into your own database, prices it locally, and gives you a dashboard you can mount in five minutes. No proxy, no SaaS account, no extra service to deploy.
9
+ Every call your app makes to OpenAI, Anthropic, Gemini, RubyLLM, or any
10
+ OpenAI-compatible API gets logged: tokens, cost, latency, tags. Calls go
11
+ app → provider direct. No proxy.
10
12
 
11
- It is not Langfuse, Helicone, or LiteLLM. It does not capture prompts, score completions, or replay traces. It does one thing: tells you which provider, which model, which feature, and which user burned how much money. That's the entire pitch.
13
+ Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend
14
+ attribution only.
12
15
 
13
- Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL, and Faraday 2.0+.
16
+ Requires Ruby 3.4+, Rails 7.1+, PostgreSQL or MySQL.
14
17
 
15
18
  ![Dashboard overview](docs/dashboard-overview.png)
16
19
 
17
- ## Accuracy model
18
-
19
- LLM Cost Tracker estimates spend from provider-reported usage and configured prices. It is useful for explaining spend by provider, model, and tags, but it is not invoice-grade billing. For reconciliation, each call keeps `provider_response_id`, `usage_source`, token breakdowns, and `pricing_mode`.
20
-
21
20
  ## Quickstart
22
21
 
23
- Add to your Gemfile alongside whatever LLM client you already use:
24
-
25
22
  ```ruby
23
+ # Gemfile
26
24
  gem "llm_cost_tracker"
27
- gem "openai" # or "anthropic", "ruby_llm", or your existing client
25
+ gem "openai"
28
26
  ```
29
27
 
30
- Install, migrate, verify:
31
-
32
28
  ```bash
33
- bin/rails generate llm_cost_tracker:install --dashboard --prices
34
- bin/rails db:migrate
35
- bin/rails llm_cost_tracker:doctor
29
+ bin/rails llm_cost_tracker:setup
36
30
  ```
37
31
 
38
- Drop this into `config/initializers/llm_cost_tracker.rb`:
32
+ Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`.
39
33
 
40
34
  ```ruby
35
+ # config/initializers/llm_cost_tracker.rb
41
36
  LlmCostTracker.configure do |config|
42
37
  config.default_tags = -> { { environment: Rails.env } }
43
38
  config.instrument :openai
44
39
  end
45
40
  ```
46
41
 
47
- Now every OpenAI call is recorded. Wrap calls in `with_tags` to attribute spend to a user, feature, or anything else you care about:
42
+ Tag your calls that's how you find out who burned the money:
48
43
 
49
44
  ```ruby
50
45
  LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
@@ -53,240 +48,85 @@ LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
53
48
  end
54
49
  ```
55
50
 
56
- Visit `/llm-costs` for the dashboard. **Mount it behind your app's auth before deploying** — the gem doesn't ship with one, on purpose.
57
-
58
- ## What you get
59
-
60
- - Local ActiveRecord ledger of every call: provider, model, token breakdown, cost, latency, tags, response IDs
61
- - Auto-capture for RubyLLM and the official `openai` and `anthropic` Ruby SDKs, plus Faraday middleware for `ruby-openai`, the Gemini REST API, and any client you can inject middleware into
62
- - Server-rendered dashboard (plain ERB, zero JavaScript) with overview, models, calls, tags, CSV export, and a data-quality page
63
- - Local pricing snapshots refreshed daily from the official provider pricing pages, applied with `bin/rails llm_cost_tracker:prices:refresh`
64
- - Monthly / daily / per-call budget guardrails with notify, raise, or block-requests behaviour
65
- - Tag-based attribution that survives concurrency — Puma threads and Sidekiq fibers don't bleed into each other
66
-
67
- ## What it deliberately doesn't do
68
-
69
- - **Doesn't run as a proxy.** Calls go directly from your app to the provider.
70
- - **Doesn't store prompts or completions.** Token counts, model, cost, tags, response IDs only. Nothing else.
71
- - **Doesn't promise invoice-grade accuracy.** It uses official provider pricing pages, but enterprise rates, batch discounts on unsupported endpoints, and modality tiers are not always modeled. `provider_response_id` is stored as a join key for whoever does that reconciliation.
72
- - **Doesn't ship with auth on the dashboard.** It's a Rails Engine; mount it behind whatever your app already uses (Devise, basic auth, Cloudflare Access, your own session middleware).
73
- - **Doesn't centralize multi-service visibility.** One Rails monolith — perfect fit. Six services in four languages — wrong tool, look at a proxy or API-layer gateway.
74
-
75
- ## Capturing calls
76
-
77
- Three paths, in order of preference. Use the first one that fits your stack.
78
-
79
- ### 1. SDK integrations
80
-
81
- Drop-in for RubyLLM and the official `openai` and `anthropic` gems. `config.instrument` patches tested SDK methods so you don't change a single call site:
51
+ Mount the dashboard in `config/routes.rb`, behind your auth:
82
52
 
83
53
  ```ruby
84
- LlmCostTracker.configure do |config|
85
- config.instrument :openai # or :anthropic / :ruby_llm
86
- end
87
-
88
- LlmCostTracker.with_tags(feature: "support_chat") do
89
- Anthropic::Client.new.messages.create(
90
- model: "claude-sonnet-4-6",
91
- max_tokens: 1024,
92
- messages: [{ role: "user", content: "Hello" }]
93
- )
54
+ authenticate :admin do
55
+ mount LlmCostTracker::Engine => "/llm-costs"
94
56
  end
95
57
  ```
96
58
 
97
- Captures usage, model, latency, response ID, pricing mode, cache tokens, Anthropic cache-write TTLs, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
98
-
99
- Enabled integrations are checked at boot: the client gem must be loaded, meet the minimum supported version, and expose the expected classes and methods. If the contract check fails, boot raises instead of silently missing spend.
59
+ The engine ships without authentication on purpose.
100
60
 
101
- This patches **only** RubyLLM and the official Ruby SDKs. `ruby-openai` (alexrudall) and any custom client go through Faraday middleware below.
61
+ ## What lands in the ledger
102
62
 
103
- ### 2. Faraday middleware
63
+ - **Calls.** Provider, model, total tokens, total cost, latency, status.
64
+ - **Line items.** Per-component breakdown — text/audio/cached tokens, tool
65
+ charges (web search, code execution, grounding, container sessions).
66
+ - **Tags.** Whatever attribution you pass — user, feature, tenant, env.
67
+ - **Provider IDs.** Response, project, API key, workspace — for downstream
68
+ audits.
69
+ - **Pricing snapshot.** So historical numbers don't drift when prices change.
104
70
 
105
- For `ruby-openai`, the Gemini REST API, custom Faraday clients, or anything OpenAI-compatible (OpenRouter, DeepSeek, Groq, LiteLLM proxies):
71
+ ## Capture surfaces
106
72
 
107
- ```ruby
108
- conn = Faraday.new(url: "https://api.openai.com") do |f|
109
- f.use :llm_cost_tracker, tags: -> { { feature: "chat", user_id: Current.user&.id } }
110
- f.request :json
111
- f.response :json
112
- f.adapter Faraday.default_adapter
113
- end
114
- ```
73
+ | Surface | Path |
74
+ | --- | --- |
75
+ | OpenAI | Official SDK or Faraday |
76
+ | Anthropic | Official SDK or Faraday |
77
+ | Google Gemini | Faraday |
78
+ | RubyLLM | Provider layer |
79
+ | `ruby-openai` | Faraday |
80
+ | OpenRouter, DeepSeek, Groq, LiteLLM-style gateways | OpenAI-compatible Faraday |
81
+ | Anything else | `LlmCostTracker.track` |
115
82
 
116
- Tags can be a hash or a callable evaluated per request. Place the middleware where it sees the final response body in practice, before the JSON parser.
83
+ Streams capture when the provider emits final usage. OpenAI Faraday streams
84
+ need `stream_options: { include_usage: true }`.
117
85
 
118
- Streaming works through the same path: the middleware tees the `on_data` callback so your code keeps receiving chunks normally, and the final usage gets recorded once the stream finishes. OpenAI streams need `stream_options: { include_usage: true }` for the final usage event.
86
+ ## What it isn't
119
87
 
120
- Per-client setup snippets for `ruby-openai`, Azure OpenAI, LiteLLM proxy, and Gemini live in [`docs/cookbook.md`](docs/cookbook.md).
88
+ - No proxy. Direct calls only.
89
+ - No prompts. Token counts and metadata only.
90
+ - No traces, evals, or prompt management. Different product, different gem.
91
+ - Not multi-service. Built for a Rails monolith.
121
92
 
122
- ### 3. Manual `track` / `track_stream`
93
+ ## Manual tracking
123
94
 
124
- When you have a client that doesn't expose Faraday and isn't an official SDK — internal gateways, homegrown wrappers, batch jobs replaying historical usage:
95
+ For batch jobs, internal gateways, or anything without an SDK/Faraday hook:
125
96
 
126
97
  ```ruby
127
98
  LlmCostTracker.track(
128
99
  provider: :anthropic,
129
100
  model: "claude-sonnet-4-6",
130
- input_tokens: 1500,
131
- output_tokens: 320,
132
- feature: "summarizer",
133
- user_id: current_user.id
101
+ tokens: { input: 1500, output: 320 },
102
+ tags: { feature: "summarizer", user_id: current_user.id }
134
103
  )
135
104
  ```
136
105
 
137
- For streaming the same way, `track_stream` accepts a block, parses provider events automatically, and records once the stream finishes. Full reference in [`docs/streaming.md`](docs/streaming.md).
138
-
139
- ## Tags: who burned this money
140
-
141
- Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings, stored as JSONB on PostgreSQL or JSON on MySQL, and queryable from both Ruby and the dashboard.
142
-
143
- ```ruby
144
- LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat") do
145
- client.chat(parameters: { model: "gpt-4o", messages: [...] })
146
- end
147
- ```
148
-
149
- `with_tags` is thread- and fiber-isolated, so concurrent requests in Puma or jobs in Sidekiq don't bleed into each other. A `default_tags` callable on configuration runs on every event for things you always want — `environment`, `region`, deployment SHA. Explicit tags passed to `track` win over scoped tags, scoped tags win over defaults.
150
-
151
- Streaming capture snapshots tags when the stream starts, so attribution survives delayed or cross-thread stream consumption.
152
-
153
- What you put in tags is **your** input — they're queryable strings. Don't put prompts, completions, emails, or secrets there. Use IDs.
154
-
155
- ## Pricing
156
-
157
- Built-in prices live in `lib/llm_cost_tracker/prices.json` and are refreshed daily from official provider pricing pages by an automated CI workflow that opens a PR on every change. Most apps run on bundled prices and never think about this.
158
-
159
- When you want to control updates yourself — for negotiated rates, gateway-specific model IDs, or pinned reviews — generate a local snapshot:
160
-
161
- ```bash
162
- bin/rails generate llm_cost_tracker:prices
163
- ```
164
-
165
- ```ruby
166
- config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
167
- ```
168
-
169
- Refresh on demand from the maintained snapshot:
170
-
171
- ```bash
172
- bin/rails llm_cost_tracker:prices:refresh
173
- ```
174
-
175
- Explain why a model is priced or unknown:
106
+ ## Docs
176
107
 
177
- ```bash
178
- PROVIDER=openai MODEL=gpt-4o bin/rails llm_cost_tracker:prices:explain
179
- ```
180
-
181
- Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
182
-
183
- `pricing_mode` selects mode-prefixed rates such as `batch_input` or `priority_output`. Built-in capture fills it from provider tier fields when available; explicit `track` calls can pass it directly for batch jobs or gateway-specific modes. Full pricing reference: [`docs/pricing.md`](docs/pricing.md).
184
-
185
- ## Budgets
186
-
187
- Budgets are guardrails, not transactional caps:
188
-
189
- ```ruby
190
- config.monthly_budget = 500.00
191
- config.daily_budget = 50.00
192
- config.per_call_budget = 2.00
193
- config.budget_exceeded_behavior = :block_requests # or :notify, :raise
194
- config.on_budget_exceeded = ->(data) { SlackNotifier.notify("#alerts", "...") }
195
- ```
196
-
197
- `:block_requests` reads ledger totals before a call goes out and stops it if you're already over. Under concurrency multiple workers can pass preflight at the same time and collectively overshoot — this catches the next call after the overshoot becomes visible, not the overshoot itself. For a strict cap, use a provider-side limit or a transactional counter outside the gem.
198
-
199
- Full behavior, error class, and preflight details: [`docs/budgets.md`](docs/budgets.md).
200
-
201
- ## Querying
202
-
203
- When you want to slice spend from a console, scheduled job, or your own admin page:
204
-
205
- ```ruby
206
- LlmCostTracker::Ledger::Call.this_month.cost_by_model
207
- LlmCostTracker::Ledger::Call.this_month.cost_by_tag("feature")
208
- LlmCostTracker::Ledger::Call.daily_costs(days: 7)
209
- LlmCostTracker::Ledger::Call.by_tags(user_id: 42, feature: "chat").this_month.total_cost
210
- ```
211
-
212
- A text report is also one rake task away:
213
-
214
- ```bash
215
- DAYS=7 bin/rails llm_cost_tracker:report
216
- ```
217
-
218
- Full scope and helper reference: [`docs/querying.md`](docs/querying.md).
219
-
220
- ## Dashboard
221
-
222
- Mount the engine wherever you want — it's plain ERB, no JavaScript bundle, no asset pipeline gymnastics:
223
-
224
- ```ruby
225
- # config/routes.rb
226
- mount LlmCostTracker::Engine => "/llm-costs"
227
- ```
228
-
229
- Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads the ActiveRecord ledger in `llm_api_calls`.
230
-
231
- Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs/dashboard.md).
232
-
233
- ## Supported providers
234
-
235
- | Provider | Auto-detected | Coverage |
236
- |---|:---:|---|
237
- | OpenAI | Yes | GPT-5.5/5.4/5.2/5.1/5 + pro/mini/nano variants, GPT-4.1, GPT-4o, o1/o3/o4-mini |
238
- | Anthropic | Yes | Claude Opus 4.7/4.6/4.5/4.1/4, Sonnet 4.6/4.5/4, Haiku 4.5 |
239
- | Google Gemini | Yes | Gemini 2.5 Pro/Flash/Flash-Lite, 2.0 Flash/Flash-Lite |
240
- | OpenRouter | Yes | OpenAI-compatible usage; provider-prefixed model IDs are normalized |
241
- | DeepSeek | Yes | OpenAI-compatible usage; add `pricing_overrides` for DeepSeek-specific rates |
242
- | Groq | Yes | OpenAI-compatible usage with bundled prices for production text models |
243
- | Other OpenAI-compatible hosts | Configurable | Register the host via `config.openai_compatible_providers` |
244
- | Anything else | Manual | Use `LlmCostTracker.track` / `track_stream` |
245
-
246
- RubyLLM chat, embedding, and transcription calls are captured through RubyLLM's provider layer when `config.instrument :ruby_llm` is enabled.
247
-
248
- Endpoints covered end-to-end: OpenAI Chat Completions / Responses / Completions / Embeddings, Anthropic Messages, Gemini `generateContent` and `streamGenerateContent`, plus their OpenAI-compatible equivalents. Streaming is captured for Faraday paths and official OpenAI / Anthropic SDK stream helpers whenever the provider emits final-usage events.
249
-
250
- ## Privacy
251
-
252
- By design, **no prompt or response content is ever stored.** Per call, the ledger holds: provider, model, token counts, cost, latency, tags, response ID, timestamp. That's it. No request bodies, no headers, no completions. Warning logs strip query strings before logging URLs.
253
-
254
- Tags carry whatever your app passes — they are application-controlled input, treat them accordingly. Use `user_id`, not the user's email; use a feature key, not the input prompt.
255
-
256
- ## Documentation
257
-
258
- Deeper guides live in `docs/`. Reference pages are being filled out as content
259
- moves out of this README; the inline sections above remain canonical where a page
260
- is still brief.
261
-
262
- - [Configuration reference](docs/configuration.md)
263
- - [Pricing & price refresh](docs/pricing.md)
264
- - [Budgets & guardrails](docs/budgets.md)
265
- - [Querying & reports](docs/querying.md)
266
- - [Dashboard mounting](docs/dashboard.md)
267
- - [Streaming capture](docs/streaming.md)
108
+ - [Configuration](docs/configuration.md)
109
+ - [Pricing](docs/pricing.md)
110
+ - [Budgets](docs/budgets.md)
111
+ - [Data model](docs/data-model.md)
112
+ - [Querying](docs/querying.md)
113
+ - [Dashboard](docs/dashboard.md)
114
+ - [Streaming](docs/streaming.md)
115
+ - [Cookbook](docs/cookbook.md)
268
116
  - [Extending](docs/extending.md)
269
- - [Production operations](docs/operations.md)
117
+ - [Operations](docs/operations.md)
118
+ - [Architecture](docs/architecture.md)
119
+ - [EU AI Act record-keeping](docs/eu_ai_act.md)
270
120
  - [Upgrading](docs/upgrading.md)
271
- - [Cookbook — per-client recipes](docs/cookbook.md)
272
- - [Architecture & design rules](docs/architecture.md)
273
-
274
- ## Known limitations
275
-
276
- - `:block_requests` is best-effort under concurrency, not a transactional cap.
277
- - Streaming usage capture relies on the provider emitting a final-usage event. Missing events are stored with `usage_source: "unknown"` so they appear on the data-quality page rather than vanishing.
278
- - Non-token line items such as Gemini explicit-cache storage duration, provider tool calls, and modality-specific surcharges are not folded into token cost.
279
- - `provider_response_id` is stored only when the provider exposes a stable ID. Gemini is best-effort and varies by endpoint.
121
+ - [Changelog](CHANGELOG.md)
280
122
 
281
123
  ## Development
282
124
 
283
125
  ```bash
284
126
  bundle install
285
- bin/check # rubocop + rspec + coverage gate
127
+ bin/check
286
128
  ```
287
129
 
288
- Architecture rules and conventions for contributions live in [`docs/architecture.md`](docs/architecture.md).
289
-
290
130
  ## License
291
131
 
292
132
  MIT — see [LICENSE.txt](LICENSE.txt).