llm_cost_tracker 0.7.0 → 0.7.1

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -9
  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 +182 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +28 -35
  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 +52 -34
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  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 +35 -36
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  89. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  91. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  92. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  93. data/lib/llm_cost_tracker/parsers.rb +20 -0
  94. data/lib/llm_cost_tracker/prices.json +52 -11
  95. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  96. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  97. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  98. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  99. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  100. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  101. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  102. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  104. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  105. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  106. data/lib/llm_cost_tracker/pricing.rb +33 -32
  107. data/lib/llm_cost_tracker/railtie.rb +7 -8
  108. data/lib/llm_cost_tracker/report/data.rb +72 -0
  109. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  110. data/lib/llm_cost_tracker/report.rb +8 -8
  111. data/lib/llm_cost_tracker/retention.rb +27 -10
  112. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  113. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  114. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  115. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  116. data/lib/llm_cost_tracker/tracker.rb +38 -70
  117. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  118. data/lib/llm_cost_tracker/version.rb +1 -1
  119. data/lib/llm_cost_tracker.rb +56 -78
  120. data/lib/tasks/llm_cost_tracker.rake +18 -13
  121. metadata +54 -58
  122. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  123. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  124. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  125. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  126. data/lib/llm_cost_tracker/cost.rb +0 -12
  127. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  128. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  129. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  130. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  131. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  132. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  133. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  134. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  135. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  136. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  137. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  138. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  139. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  140. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  141. data/lib/llm_cost_tracker/period_total.rb +0 -9
  142. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  143. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  144. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  145. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  146. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  147. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  148. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  149. data/lib/llm_cost_tracker/report_data.rb +0 -94
  150. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  151. data/lib/llm_cost_tracker/request_url.rb +0 -20
  152. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  153. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  154. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  155. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  156. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  157. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  158. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  159. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  160. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  161. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  162. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  163. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  164. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  165. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  166. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  167. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  168. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  169. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  170. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  171. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  172. 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: fbf918d9a4886e24ba99f93dc1125e016e50b45208437dd3254adda76f58033a
4
+ data.tar.gz: 8a22ccfa517549f55d2a302287660a8a5d7faf03568e80c253e6024ad4988743
5
5
  SHA512:
6
- metadata.gz: 4c7d9869224101f85298dab0b4395b0c2b96634f46f24e76487412f0f8418e4270fb15124f6f238499c3205f4ad2ebae6e8bcba1b265f7496e3509b7da7c5ea9
7
- data.tar.gz: f07e91cf62de94d7e7c76eff7ec4b798208724df5722374d6a72884445a70a64ef83421b90aa8be51b887e277f3e8bcb7eec3ad14987eec25609483bc11f0667
6
+ metadata.gz: 4ef6bc278f6f98ce37a91e515e8b4a004aaff5f573fc8580e312e53b903d1e7700b2cd58b9bea72fc0ec904010cba43fb0209e7993c31bb69205b42564554884
7
+ data.tar.gz: 4c9a193d16bb5e8bfa58aaefb6fb9b7d98a632a8e19203927c3fd29b957da6088af71caf5c14f5f2b7452660f78001d7898745f8d7611a4fd6e94311b723bc71
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.1] - 2026-04-30
8
+
9
+ ### Changed
10
+
11
+ - BREAKING: ActiveRecord ledger write failures now raise directly; removed `storage_error_behavior` and `StorageError`.
12
+ - BREAKING: Removed custom parser and SDK integration registration APIs; use built-in capture or explicit `track` / `track_stream`.
13
+ - BREAKING: Usage and pricing APIs now use `TokenUsage`; removed `UsageBreakdown`, `add_usage_breakdown`, direct `Pricing` token arguments, and `Pricing::Cost`.
14
+ - BREAKING: `Tracker.record` now accepts `UsageCapture`, and notification payloads nest `token_usage`.
15
+ - BREAKING: Moved price registry and refresh APIs under `LlmCostTracker::Pricing`.
16
+ - BREAKING: ActiveRecord installs must run the current ledger and period-total migrations; doctor, dashboard setup, and flush now fail on stale schema.
17
+ - 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`.
18
+ - Dashboard model and data-quality pages now use canonical `TokenUsage` totals.
19
+ - OpenAI, Anthropic, and RubyLLM capture now populate `pricing_mode` from provider tier data.
20
+ - Pricing now handles Anthropic 1-hour cache-write TTLs, Gemini context-cache reads, stackable batch cache rates, and long-context tiers.
21
+ - Missing positive-token pricing-mode rates now return unknown pricing instead of falling back to standard prices.
22
+
7
23
  ## [0.7.0] - 2026-04-29
8
24
 
9
25
  ### Changed
data/README.md CHANGED
@@ -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
 
@@ -134,7 +134,7 @@ 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
140
  LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat", trace_id: request.uuid) do
@@ -172,7 +172,9 @@ Explain why a model is priced or unknown:
172
172
  PROVIDER=openai MODEL=gpt-4o bin/rails llm_cost_tracker:prices:explain
173
173
  ```
174
174
 
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).
175
+ Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
176
+
177
+ `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
178
 
177
179
  ## Budgets
178
180
 
@@ -195,10 +197,10 @@ Full behavior, error class, and preflight details: [`docs/budgets.md`](docs/budg
195
197
  When you want to slice spend from a console, scheduled job, or your own admin page:
196
198
 
197
199
  ```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
200
+ LlmCostTracker::Ledger::Call.this_month.cost_by_model
201
+ LlmCostTracker::Ledger::Call.this_month.cost_by_tag("feature")
202
+ LlmCostTracker::Ledger::Call.daily_costs(days: 7)
203
+ LlmCostTracker::Ledger::Call.by_tags(user_id: 42, feature: "chat").this_month.total_cost
202
204
  ```
203
205
 
204
206
  A text report is also one rake task away:
@@ -232,7 +234,7 @@ Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs
232
234
  | OpenRouter | Yes | OpenAI-compatible usage; provider-prefixed model IDs are normalized |
233
235
  | DeepSeek | Yes | OpenAI-compatible usage; add `pricing_overrides` for DeepSeek-specific rates |
234
236
  | 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) |
237
+ | Anything else | Manual | Use `LlmCostTracker.track` / `track_stream` |
236
238
 
237
239
  RubyLLM chat, embedding, and transcription calls are captured through RubyLLM's provider layer when `config.instrument :ruby_llm` is enabled.
238
240
 
@@ -266,8 +268,8 @@ is still brief.
266
268
 
267
269
  - `:block_requests` is best-effort under concurrency, not a transactional cap.
268
270
  - 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.
271
+ - 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
272
  - `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
273
 
272
274
  ## Development
273
275
 
@@ -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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm_cost_tracker/ledger/schema/adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Period
8
+ module Grouping
9
+ PERIOD_FORMATS = {
10
+ day: {
11
+ postgres: "YYYY-MM-DD",
12
+ mysql: "%Y-%m-%d"
13
+ },
14
+ month: {
15
+ postgres: "YYYY-MM",
16
+ mysql: "%Y-%m"
17
+ }
18
+ }.freeze
19
+
20
+ private_constant :PERIOD_FORMATS
21
+
22
+ def group_by_period(period, column: :tracked_at)
23
+ group(Arel.sql(period_group_expression(period, column: column)))
24
+ end
25
+
26
+ def daily_costs(days: 30)
27
+ where(tracked_at: days.days.ago..)
28
+ .group_by_period(:day)
29
+ .sum(:total_cost)
30
+ end
31
+
32
+ private
33
+
34
+ def period_group_expression(period, column:)
35
+ period = validated_period(period)
36
+ column = period_column_expression(column)
37
+ formats = PERIOD_FORMATS.fetch(period)
38
+
39
+ if Ledger::Schema::Adapter.postgresql?(connection)
40
+ postgres_period_expression(period, column, formats)
41
+ elsif Ledger::Schema::Adapter.mysql?(connection)
42
+ "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
43
+ else
44
+ Ledger::Schema::Adapter.ensure_supported!(connection)
45
+ end
46
+ end
47
+
48
+ def postgres_period_expression(period, column, formats)
49
+ "TO_CHAR(" \
50
+ "DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
51
+ "#{connection.quote(formats.fetch(:postgres))}" \
52
+ ")"
53
+ end
54
+
55
+ def validated_period(period)
56
+ normalized_period = period.try(:to_sym)
57
+ return normalized_period if PERIOD_FORMATS.key?(normalized_period)
58
+
59
+ raise ArgumentError, "invalid period: #{period.inspect}"
60
+ end
61
+
62
+ def period_column_expression(column)
63
+ column = column.to_s
64
+ return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
65
+
66
+ raise ArgumentError, "invalid period column: #{column.inspect}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Period
8
+ class Total < ActiveRecord::Base
9
+ self.table_name = "llm_cost_tracker_period_totals"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Tags
8
+ module Accessors
9
+ def parsed_tags
10
+ return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
11
+
12
+ JSON.parse(tags || "{}")
13
+ rescue JSON::ParserError
14
+ {}
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end