llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9de844d3302fda39c5a8b32736d8abff488a5cd6cfd00932f492a1ec701f308d
4
- data.tar.gz: a8f942605f9cfbcc77f7e998e3e504150fa820e44829156e901cb06bdcc6d0cf
3
+ metadata.gz: a5d394087953583d254479b4fe162adbb5b5a0f4de09c535428d514a6c623e76
4
+ data.tar.gz: b3269262ceec2e1f622780e3e44ac33adb1df703e077bee43fcddd7c251a21dc
5
5
  SHA512:
6
- metadata.gz: 4c7d9869224101f85298dab0b4395b0c2b96634f46f24e76487412f0f8418e4270fb15124f6f238499c3205f4ad2ebae6e8bcba1b265f7496e3509b7da7c5ea9
7
- data.tar.gz: f07e91cf62de94d7e7c76eff7ec4b798208724df5722374d6a72884445a70a64ef83421b90aa8be51b887e277f3e8bcb7eec3ad14987eec25609483bc11f0667
6
+ metadata.gz: 93ce84108bea091e89df70b28a192e280a1e3de92bdb14b8d47ca4057527ebcd4ec1ffa25fc37fc5216df4804539f5b1b9483d6f1fb1afdd292fd19e836431e5
7
+ data.tar.gz: f69fed55512f322118e93493b9069821e3cd9b372940b6163b7f80578afc3b95799126b91178b25125c7eba871ebe8b3a8fd32e607f8103649c1f3d4d606923d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.2] - 2026-05-01
8
+
9
+ ### Added
10
+
11
+ - Groq auto-detection, price scraping, and bundled production text model prices.
12
+
13
+ ### Changed
14
+
15
+ - Bundled prices refreshed from official provider pricing as of 2026-05-01.
16
+ - Bundled prices now include OpenAI Flex/Priority/regional processing, Gemini Flex/Priority, and Anthropic fast/data residency rates.
17
+
18
+ ### Fixed
19
+
20
+ - Streaming capture now snapshots tags when the stream starts.
21
+
22
+ ## [0.7.1] - 2026-04-30
23
+
24
+ ### Changed
25
+
26
+ - BREAKING: ActiveRecord ledger write failures now raise directly; removed `storage_error_behavior` and `StorageError`.
27
+ - BREAKING: Removed custom parser and SDK integration registration APIs; use built-in capture or explicit `track` / `track_stream`.
28
+ - BREAKING: Usage and pricing APIs now use `TokenUsage`; removed `UsageBreakdown`, `add_usage_breakdown`, direct `Pricing` token arguments, and `Pricing::Cost`.
29
+ - BREAKING: `Tracker.record` now accepts `UsageCapture`, and notification payloads nest `token_usage`.
30
+ - BREAKING: Moved price registry and refresh APIs under `LlmCostTracker::Pricing`.
31
+ - BREAKING: ActiveRecord installs must run the current ledger and period-total migrations; doctor, dashboard setup, and flush now fail on stale schema.
32
+ - BREAKING: `cache_write_input_tokens` now stores only standard cache writes; 1-hour cache writes use `cache_write_1h_input_tokens` and `cache_write_1h_input_cost`.
33
+ - Dashboard model and data-quality pages now use canonical `TokenUsage` totals.
34
+ - OpenAI, Anthropic, and RubyLLM capture now populate `pricing_mode` from provider tier data.
35
+ - Pricing now handles Anthropic 1-hour cache-write TTLs, Gemini context-cache reads, stackable batch cache rates, and long-context tiers.
36
+ - Missing positive-token pricing-mode rates now return unknown pricing instead of falling back to standard prices.
37
+
7
38
  ## [0.7.0] - 2026-04-29
8
39
 
9
40
  ### Changed
data/README.md CHANGED
@@ -35,7 +35,7 @@ Drop this into `config/initializers/llm_cost_tracker.rb`:
35
35
 
36
36
  ```ruby
37
37
  LlmCostTracker.configure do |config|
38
- config.default_tags = -> { { environment: Rails.env } }
38
+ config.default_tags = -> { { environment: Rails.env } }
39
39
  config.instrument :openai
40
40
  end
41
41
  ```
@@ -78,7 +78,7 @@ Drop-in for RubyLLM and the official `openai` and `anthropic` gems. `config.inst
78
78
 
79
79
  ```ruby
80
80
  LlmCostTracker.configure do |config|
81
- config.instrument :openai # or :anthropic / :ruby_llm
81
+ config.instrument :openai # or :anthropic / :ruby_llm
82
82
  end
83
83
 
84
84
  LlmCostTracker.with_tags(feature: "support_chat") do
@@ -90,7 +90,7 @@ LlmCostTracker.with_tags(feature: "support_chat") do
90
90
  end
91
91
  ```
92
92
 
93
- Captures usage, model, latency, response ID, cache tokens, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
93
+ 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.
94
94
 
95
95
  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.
96
96
 
@@ -98,7 +98,7 @@ This patches **only** RubyLLM and the official Ruby SDKs. `ruby-openai` (alexrud
98
98
 
99
99
  ### 2. Faraday middleware
100
100
 
101
- For `ruby-openai`, the Gemini REST API, custom Faraday clients, or anything OpenAI-compatible (OpenRouter, DeepSeek, LiteLLM proxies):
101
+ For `ruby-openai`, the Gemini REST API, custom Faraday clients, or anything OpenAI-compatible (OpenRouter, DeepSeek, Groq, LiteLLM proxies):
102
102
 
103
103
  ```ruby
104
104
  conn = Faraday.new(url: "https://api.openai.com") do |f|
@@ -134,16 +134,18 @@ For streaming the same way, `track_stream` accepts a block, parses provider even
134
134
 
135
135
  ## Tags: who burned this money
136
136
 
137
- Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings, indexed (JSONB on Postgres, fallback elsewhere), and queryable from both Ruby and the dashboard.
137
+ 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.
138
138
 
139
139
  ```ruby
140
- LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat", trace_id: request.uuid) do
140
+ LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat") do
141
141
  client.chat(parameters: { model: "gpt-4o", messages: [...] })
142
142
  end
143
143
  ```
144
144
 
145
145
  `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.
146
146
 
147
+ Streaming capture snapshots tags when the stream starts, so attribution survives delayed or cross-thread stream consumption.
148
+
147
149
  What you put in tags is **your** input — they're queryable strings. Don't put prompts, completions, emails, or secrets there. Use IDs.
148
150
 
149
151
  ## Pricing
@@ -172,7 +174,9 @@ Explain why a model is priced or unknown:
172
174
  PROVIDER=openai MODEL=gpt-4o bin/rails llm_cost_tracker:prices:explain
173
175
  ```
174
176
 
175
- Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys. Full pricing reference: [`docs/pricing.md`](docs/pricing.md).
177
+ Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
178
+
179
+ `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).
176
180
 
177
181
  ## Budgets
178
182
 
@@ -182,7 +186,7 @@ Budgets are guardrails, not transactional caps:
182
186
  config.monthly_budget = 500.00
183
187
  config.daily_budget = 50.00
184
188
  config.per_call_budget = 2.00
185
- config.budget_exceeded_behavior = :block_requests # or :notify, :raise
189
+ config.budget_exceeded_behavior = :block_requests # or :notify, :raise
186
190
  config.on_budget_exceeded = ->(data) { SlackNotifier.notify("#alerts", "...") }
187
191
  ```
188
192
 
@@ -195,10 +199,10 @@ Full behavior, error class, and preflight details: [`docs/budgets.md`](docs/budg
195
199
  When you want to slice spend from a console, scheduled job, or your own admin page:
196
200
 
197
201
  ```ruby
198
- LlmCostTracker::LlmApiCall.this_month.cost_by_model
199
- LlmCostTracker::LlmApiCall.this_month.cost_by_tag("feature")
200
- LlmCostTracker::LlmApiCall.daily_costs(days: 7)
201
- LlmCostTracker::LlmApiCall.by_tags(user_id: 42, feature: "chat").this_month.total_cost
202
+ LlmCostTracker::Ledger::Call.this_month.cost_by_model
203
+ LlmCostTracker::Ledger::Call.this_month.cost_by_tag("feature")
204
+ LlmCostTracker::Ledger::Call.daily_costs(days: 7)
205
+ LlmCostTracker::Ledger::Call.by_tags(user_id: 42, feature: "chat").this_month.total_cost
202
206
  ```
203
207
 
204
208
  A text report is also one rake task away:
@@ -231,8 +235,9 @@ Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs
231
235
  | Google Gemini | Yes | Gemini 2.5 Pro/Flash/Flash-Lite, 2.0 Flash/Flash-Lite |
232
236
  | OpenRouter | Yes | OpenAI-compatible usage; provider-prefixed model IDs are normalized |
233
237
  | DeepSeek | Yes | OpenAI-compatible usage; add `pricing_overrides` for DeepSeek-specific rates |
238
+ | Groq | Yes | OpenAI-compatible usage with bundled prices for production text models |
234
239
  | Other OpenAI-compatible hosts | Configurable | Register the host via `config.openai_compatible_providers` |
235
- | Anything else | Configurable | Custom parser — see [`docs/extending.md`](docs/extending.md) |
240
+ | Anything else | Manual | Use `LlmCostTracker.track` / `track_stream` |
236
241
 
237
242
  RubyLLM chat, embedding, and transcription calls are captured through RubyLLM's provider layer when `config.instrument :ruby_llm` is enabled.
238
243
 
@@ -266,17 +271,17 @@ is still brief.
266
271
 
267
272
  - `:block_requests` is best-effort under concurrency, not a transactional cap.
268
273
  - 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.
274
+ - Non-token line items such as Gemini explicit-cache storage duration, provider tool calls, and modality-specific surcharges are not folded into token cost.
269
275
  - `provider_response_id` is stored only when the provider exposes a stable ID. Gemini is best-effort and varies by endpoint.
270
- - Cache write TTL variants on Anthropic (1h vs 5min writes) are not modeled separately yet.
271
276
 
272
277
  ## Development
273
278
 
274
279
  ```bash
275
280
  bundle install
276
- bin/check # rubocop + rspec + coverage gate
281
+ bin/check # rubocop + rspec + coverage gate
277
282
  ```
278
283
 
279
- Architecture rules and conventions for contributions live in [`AGENTS.md`](AGENTS.md) and [`docs/architecture.md`](docs/architecture.md).
284
+ Architecture rules and conventions for contributions live in [`docs/architecture.md`](docs/architecture.md).
280
285
 
281
286
  ## License
282
287
 
@@ -302,6 +302,9 @@
302
302
  .lct-budget-fill--warn { background: linear-gradient(90deg, #f59e0b, #d97706); }
303
303
  .lct-budget-fill--over { background: linear-gradient(90deg, #ef4444, #b91c1c); }
304
304
  .lct-stack-fill-input { background: var(--lct-accent); }
305
+ .lct-stack-fill-cache-read { background: #22c55e; }
306
+ .lct-stack-fill-cache-write { background: #f59e0b; }
307
+ .lct-stack-fill-cache-write-1h { background: #a855f7; }
305
308
  .lct-stack-fill-output { background: #0ea5e9; }
306
309
 
307
310
  .lct-budget { display: grid; gap: 10px; }
@@ -4,7 +4,7 @@ module LlmCostTracker
4
4
  class ApplicationController < ActionController::Base
5
5
  layout "llm_cost_tracker/application"
6
6
 
7
- before_action :ensure_llm_api_calls_table
7
+ before_action :ensure_current_schema
8
8
 
9
9
  rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
10
10
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
@@ -13,9 +13,27 @@ module LlmCostTracker
13
13
 
14
14
  private
15
15
 
16
- def ensure_llm_api_calls_table
17
- return if LlmCostTracker::LlmApiCall.table_exists?
18
-
16
+ def ensure_current_schema
17
+ unless LlmCostTracker::Ledger::Call.table_exists?
18
+ @setup_message = "The llm_api_calls table is not available yet."
19
+ return render template: "llm_cost_tracker/shared/setup_required"
20
+ end
21
+
22
+ schema_errors = LlmCostTracker::Ledger::Schema::Calls.current_schema_errors
23
+ if schema_errors.any?
24
+ @setup_message = "The llm_api_calls table does not match the current LLM Cost Tracker schema."
25
+ @setup_details = schema_errors
26
+ render template: "llm_cost_tracker/shared/setup_required"
27
+ return
28
+ end
29
+
30
+ period_total_errors = LlmCostTracker::Ledger::Schema::PeriodTotals.current_schema_errors
31
+ return if period_total_errors.empty?
32
+
33
+ @setup_message = "The llm_cost_tracker_period_totals table does not match the current LLM Cost Tracker schema."
34
+ @setup_details = period_total_errors + [
35
+ "run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
36
+ ]
19
37
  render template: "llm_cost_tracker/shared/setup_required"
20
38
  end
21
39
 
@@ -14,11 +14,10 @@ module LlmCostTracker
14
14
  scope = Dashboard::Filter.call(params: params)
15
15
  scope = scope.unknown_pricing if @sort == "unknown_pricing"
16
16
  ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
17
- @latency_available = LlmApiCall.latency_column?
18
17
 
19
18
  respond_to do |format|
20
19
  format.html do
21
- @page = Pagination.call(params)
20
+ @page = Dashboard::Pagination.call(params)
22
21
  @calls_count = scope.count
23
22
  @calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
24
23
  end
@@ -31,8 +30,7 @@ module LlmCostTracker
31
30
  end
32
31
 
33
32
  def show
34
- @call = LlmApiCall.find(params[:id])
35
- @latency_available = LlmApiCall.latency_column?
33
+ @call = Ledger::Call.find(params[:id])
36
34
  end
37
35
 
38
36
  private
@@ -46,8 +44,6 @@ module LlmCostTracker
46
44
  when "output"
47
45
  "output_tokens DESC, #{DEFAULT_ORDER}"
48
46
  when "slow"
49
- return DEFAULT_ORDER unless LlmApiCall.latency_column?
50
-
51
47
  "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
52
48
  else
53
49
  DEFAULT_ORDER
@@ -66,11 +62,10 @@ module LlmCostTracker
66
62
  end
67
63
 
68
64
  def csv_fields
69
- fields = %i[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
70
- fields << :latency_ms if LlmApiCall.latency_column?
71
- fields << :provider_response_id if LlmApiCall.provider_response_id_column?
72
- fields << :tags
73
- fields
65
+ %i[tracked_at provider model] +
66
+ TokenUsage::STORED_KEYS +
67
+ Pricing::COST_KEYS +
68
+ %i[latency_ms provider_response_id tags]
74
69
  end
75
70
 
76
71
  def csv_value(field, value)
@@ -7,7 +7,7 @@ module LlmCostTracker
7
7
  @from_date = range.from
8
8
  @to_date = range.to
9
9
  prev_from, prev_to = previous_range
10
- filter_params = LlmCostTracker::ParameterHash.to_hash(params)
10
+ filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
11
11
  scope = Dashboard::Filter.call(
12
12
  params: filter_params.merge("from" => @from_date.iso8601, "to" => @to_date.iso8601)
13
13
  )
@@ -16,6 +16,7 @@ module LlmCostTracker
16
16
  )
17
17
 
18
18
  @stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
19
+ @monthly_budget_status = Dashboard::OverviewStats.monthly_budget_status
19
20
  @time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
20
21
  @comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
21
22
  @spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
@@ -3,7 +3,11 @@
3
3
  module LlmCostTracker
4
4
  class DataQualityController < ApplicationController
5
5
  def index
6
- @stats = Dashboard::DataQuality.call(scope: Dashboard::Filter.call(params: params))
6
+ scope = Dashboard::Filter.call(params: params)
7
+ @stats = Dashboard::DataQuality.call(scope: scope)
8
+ @usage_rows = Dashboard::DataQuality.usage_rows(@stats)
9
+ @hidden_output_summary = Dashboard::DataQuality.hidden_output_summary(@stats)
10
+ @unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(scope)
7
11
  end
8
12
  end
9
13
  end
@@ -9,7 +9,6 @@ module LlmCostTracker
9
9
  limit: nil,
10
10
  sort: @sort
11
11
  )
12
- @latency_available = LlmApiCall.latency_column?
13
12
  end
14
13
  end
15
14
  end
@@ -7,14 +7,7 @@ module LlmCostTracker
7
7
  end
8
8
 
9
9
  def show
10
- @tag_key = params[:key]
11
- breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
12
- @rows = breakdown.rows
13
- @total_calls = breakdown.total_calls
14
- @tagged_calls = breakdown.tagged_calls
15
- @distinct_values = breakdown.distinct_values
16
- @tag_value_limit = breakdown.limit
17
- @tag_values_limited = breakdown.limited?
10
+ @breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: params[:key])
18
11
  end
19
12
  end
20
13
  end
@@ -12,6 +12,7 @@ module LlmCostTracker
12
12
  include DashboardQueryHelper
13
13
  include ChartHelper
14
14
  include PaginationHelper
15
+ include TokenUsageHelper
15
16
 
16
17
  def coverage_percent(numerator, denominator)
17
18
  return 0.0 unless denominator.to_i.positive?
@@ -43,7 +44,7 @@ module LlmCostTracker
43
44
  end
44
45
 
45
46
  def format_date(value)
46
- value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d %H:%M") : value.to_s
47
+ value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
47
48
  end
48
49
 
49
50
  def pricing_status(call)
@@ -14,8 +14,7 @@ module LlmCostTracker
14
14
  end
15
15
 
16
16
  def active_tag_filters
17
- tag_params = normalized_query_tags(params[:tag])
18
- return [] unless tag_params.is_a?(Hash)
17
+ tag_params = LlmCostTracker::Dashboard::Params.to_hash(params[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
19
18
 
20
19
  tag_params.filter_map do |key, value|
21
20
  next if key.blank? || value.blank?
@@ -15,7 +15,7 @@ module LlmCostTracker
15
15
  private
16
16
 
17
17
  def filter_options_for(column, filter_params:)
18
- source = LlmCostTracker::ParameterHash.to_hash(filter_params)
18
+ source = LlmCostTracker::Dashboard::Params.to_hash(filter_params)
19
19
  scope_params = source.stringify_keys.merge(
20
20
  column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
21
21
  )
@@ -11,44 +11,27 @@ module LlmCostTracker
11
11
 
12
12
  def calls_query_for_tag(key:, value:)
13
13
  query = current_query(page: nil, per: nil, format: nil)
14
- tags = normalized_query_tags(query[:tag])
14
+ tags = LlmCostTracker::Dashboard::Params.to_hash(query[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
15
15
  query[:tag] = tags.merge(key.to_s => value.to_s)
16
16
  query
17
17
  end
18
18
 
19
19
  private
20
20
 
21
- def normalized_query_tags(tags)
22
- LlmCostTracker::ParameterHash.to_hash(tags).transform_keys(&:to_s).transform_values(&:to_s)
23
- end
24
-
25
21
  def clean_dashboard_query(value)
26
- if LlmCostTracker::ParameterHash.hash_like?(value)
27
- return clean_dashboard_hash(LlmCostTracker::ParameterHash.to_hash(value))
28
- end
22
+ if value.is_a?(Hash) || value.try(:to_unsafe_h).is_a?(Hash)
23
+ return LlmCostTracker::Dashboard::Params.to_hash(value).each_with_object({}) do |(key, nested), cleaned|
24
+ nested = clean_dashboard_query(nested)
25
+ next if nested.nil? || nested == {} || nested == []
29
26
 
30
- return clean_dashboard_array(value) if value.is_a?(Array)
31
- return clean_dashboard_string(value) if value.is_a?(String)
32
-
33
- value
34
- end
35
-
36
- def clean_dashboard_hash(hash)
37
- hash.each_with_object({}) do |(key, nested), cleaned|
38
- nested = clean_dashboard_query(nested)
39
- next if nested.nil? || nested == {} || nested == []
40
-
41
- cleaned[key] = nested
27
+ cleaned[key] = nested
28
+ end
42
29
  end
43
- end
44
30
 
45
- def clean_dashboard_array(array)
46
- array.filter_map { |item| clean_dashboard_query(item) }.presence
47
- end
31
+ return value.filter_map { |item| clean_dashboard_query(item) }.presence if value.is_a?(Array)
32
+ return value.strip.presence if value.is_a?(String)
48
33
 
49
- def clean_dashboard_string(string)
50
- stripped = string.strip
51
- stripped.empty? ? nil : stripped
34
+ value
52
35
  end
53
36
  end
54
37
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module TokenUsageHelper
5
+ COMPONENT_LABELS = {
6
+ input_tokens: "Input",
7
+ cache_read_input_tokens: "Cache read",
8
+ cache_write_input_tokens: "Cache write",
9
+ cache_write_1h_input_tokens: "1h cache write",
10
+ output_tokens: "Output",
11
+ hidden_output_tokens: "Hidden output"
12
+ }.freeze
13
+ QUALITY_LABELS = COMPONENT_LABELS.merge(
14
+ input_tokens: "Regular input",
15
+ cache_read_input_tokens: "Cache read input",
16
+ cache_write_input_tokens: "Cache write input",
17
+ cache_write_1h_input_tokens: "1h cache write input"
18
+ ).freeze
19
+ STACK_CLASSES = {
20
+ input_tokens: "lct-stack-fill-input",
21
+ cache_read_input_tokens: "lct-stack-fill-cache-read",
22
+ cache_write_input_tokens: "lct-stack-fill-cache-write",
23
+ cache_write_1h_input_tokens: "lct-stack-fill-cache-write-1h",
24
+ output_tokens: "lct-stack-fill-output"
25
+ }.freeze
26
+
27
+ def token_usage_stack_components
28
+ token_usage_display_components(labels: COMPONENT_LABELS).select do |component|
29
+ component.fetch(:cost_key)
30
+ end
31
+ end
32
+
33
+ def token_usage_quality_label(token_key)
34
+ QUALITY_LABELS.fetch(token_key.to_sym)
35
+ end
36
+
37
+ private
38
+
39
+ def token_usage_display_components(labels:)
40
+ LlmCostTracker::Pricing::COMPONENTS.map do |component|
41
+ token_key = component.token_key
42
+ {
43
+ token_key: token_key,
44
+ cost_key: component.cost_key,
45
+ label: labels.fetch(token_key),
46
+ css_class: STACK_CLASSES[token_key]
47
+ }
48
+ end + [
49
+ {
50
+ token_key: :hidden_output_tokens,
51
+ cost_key: nil,
52
+ label: labels.fetch(:hidden_output_tokens),
53
+ css_class: nil
54
+ }
55
+ ]
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ module Ingestion
7
+ class Event < ActiveRecord::Base
8
+ MAX_ATTEMPTS = 5
9
+
10
+ self.table_name = "llm_cost_tracker_inbox_events"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ module Ingestion
7
+ class Lease < ActiveRecord::Base
8
+ self.table_name = "llm_cost_tracker_ingestor_leases"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ class Call < ActiveRecord::Base
8
+ extend Period::Grouping
9
+ extend Ledger::CallMetrics
10
+ include Ledger::Tags::Accessors
11
+
12
+ self.table_name = "llm_api_calls"
13
+
14
+ scope :with_cost, -> { where.not(total_cost: nil) }
15
+ scope :without_cost, -> { where(total_cost: nil) }
16
+ scope :unknown_pricing, -> { without_cost }
17
+ scope :with_latency, -> { where.not(latency_ms: nil) }
18
+ scope :streaming, -> { where(stream: true) }
19
+ scope :non_streaming, -> { where(stream: [false, nil]) }
20
+ scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
21
+ scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
22
+ scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
23
+ scope :streaming_missing_usage, lambda {
24
+ where(stream: true).where(usage_source: ["unknown", nil])
25
+ }
26
+
27
+ scope :with_json_tags, lambda {
28
+ where.not(tags: {})
29
+ }
30
+
31
+ scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
32
+ scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
33
+ scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
34
+ scope :between, ->(from, to) { where(tracked_at: from..to) }
35
+
36
+ def self.by_tag(key, value)
37
+ by_tags(key => value)
38
+ end
39
+
40
+ def self.by_tags(tags)
41
+ Ledger::Tags::Query.apply(self, tags)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm_cost_tracker/ledger/tags/sql"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module CallMetrics
8
+ def total_cost
9
+ sum(:total_cost).to_f
10
+ end
11
+
12
+ def total_tokens
13
+ sum(:total_tokens).to_i
14
+ end
15
+
16
+ def cost_by_model(limit: nil)
17
+ cost_by_column(:model, limit: limit)
18
+ end
19
+
20
+ def cost_by_provider(limit: nil)
21
+ cost_by_column(:provider, limit: limit)
22
+ end
23
+
24
+ def group_by_tag(key)
25
+ group(Arel.sql(tag_value_expression(key)))
26
+ end
27
+
28
+ def cost_by_tag(key, limit: nil)
29
+ expression = tag_value_expression(key)
30
+ label_expression = "COALESCE(NULLIF(#{expression}, ''), #{connection.quote('(untagged)')})"
31
+ relation = select("#{label_expression} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
32
+ .group(Arel.sql(label_expression))
33
+ .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
34
+ relation = relation.limit(limit) if limit
35
+ relation
36
+ end
37
+
38
+ def average_latency_ms
39
+ average(:latency_ms)&.to_f
40
+ end
41
+
42
+ def latency_by_model
43
+ group(:model).average(:latency_ms).transform_values(&:to_f)
44
+ end
45
+
46
+ def latency_by_provider
47
+ group(:provider).average(:latency_ms).transform_values(&:to_f)
48
+ end
49
+
50
+ def tag_value_expression(key, table_name: quoted_table_name)
51
+ Ledger::Tags::Sql.value_expression(self, key, table_name: table_name)
52
+ end
53
+
54
+ private
55
+
56
+ def cost_by_column(column, limit:)
57
+ quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
58
+ relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
59
+ .group(column)
60
+ .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
61
+ relation = relation.limit(limit) if limit
62
+ relation
63
+ end
64
+ end
65
+ end
66
+ end