llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,19 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/billing/components"
4
- require "llm_cost_tracker/ledger/schema/adapter"
5
-
6
3
  module LlmCostTracker
7
4
  module Dashboard
8
- class DataQuality
5
+ module DataQuality
9
6
  UnknownPricingRow = ::Data.define(:provider, :model, :calls, :share_percent)
10
7
  StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
11
- Summary = ::Data.define(:total, :unknown_pricing_count, :untagged_calls_count, :missing_latency_count,
12
- :streaming_count, :streaming_missing_usage, :missing_provider_response_id_count,
13
- :calls_with_pricing, :tagged_calls, :calls_with_latency, :streams_with_usage,
14
- :calls_with_provider_response_id, :unknown_pricing_share, :untagged_share,
15
- :missing_latency_share, :streaming_share, :streaming_missing_usage_share,
16
- :cost_coverage, :tag_coverage, :latency_coverage, :stream_coverage,
8
+ Summary = ::Data.define(:total,
9
+ :unknown_pricing_count,
10
+ :untagged_calls_count,
11
+ :missing_latency_count,
12
+ :streaming_count,
13
+ :streaming_missing_usage,
14
+ :missing_provider_response_id_count,
15
+ :calls_with_pricing,
16
+ :tagged_calls,
17
+ :calls_with_latency,
18
+ :streams_with_usage,
19
+ :calls_with_provider_response_id,
20
+ :unknown_pricing_share,
21
+ :untagged_share,
22
+ :missing_latency_share,
23
+ :streaming_share,
24
+ :streaming_missing_usage_share,
25
+ :cost_coverage,
26
+ :tag_coverage,
27
+ :latency_coverage,
28
+ :stream_coverage,
17
29
  :provider_response_id_coverage)
18
30
 
19
31
  class << self
@@ -36,14 +48,28 @@ module LlmCostTracker
36
48
  calls_with_provider_response_id = total - missing_provider_response_id_count
37
49
 
38
50
  Summary.new(
39
- total, unknown_pricing_count, untagged_calls_count, missing_latency_count, streaming_count,
40
- streaming_missing_usage, missing_provider_response_id_count, calls_with_pricing, tagged_calls,
41
- calls_with_latency, streams_with_usage, calls_with_provider_response_id,
42
- percentage(unknown_pricing_count, total), percentage(untagged_calls_count, total),
43
- percentage(missing_latency_count, total), percentage(streaming_count, total),
44
- percentage(streaming_missing_usage, streaming_count), percentage(calls_with_pricing, total),
45
- percentage(tagged_calls, total), percentage(calls_with_latency, total),
46
- percentage(streams_with_usage, streaming_count), percentage(calls_with_provider_response_id, total)
51
+ total,
52
+ unknown_pricing_count,
53
+ untagged_calls_count,
54
+ missing_latency_count,
55
+ streaming_count,
56
+ streaming_missing_usage,
57
+ missing_provider_response_id_count,
58
+ calls_with_pricing,
59
+ tagged_calls,
60
+ calls_with_latency,
61
+ streams_with_usage,
62
+ calls_with_provider_response_id,
63
+ percentage(unknown_pricing_count, total),
64
+ percentage(untagged_calls_count, total),
65
+ percentage(missing_latency_count, total),
66
+ percentage(streaming_count, total),
67
+ percentage(streaming_missing_usage, streaming_count),
68
+ percentage(calls_with_pricing, total),
69
+ percentage(tagged_calls, total),
70
+ percentage(calls_with_latency, total),
71
+ percentage(streams_with_usage, streaming_count),
72
+ percentage(calls_with_provider_response_id, total)
47
73
  )
48
74
  end
49
75
 
@@ -55,7 +81,9 @@ module LlmCostTracker
55
81
  .limit(10)
56
82
  .map do |row|
57
83
  calls = row.calls.to_i
58
- UnknownPricingRow.new(provider: row.provider, model: row.model, calls: calls,
84
+ UnknownPricingRow.new(provider: row.provider,
85
+ model: row.model,
86
+ calls: calls,
59
87
  share_percent: percentage(calls, total_calls))
60
88
  end
61
89
  end
@@ -85,7 +113,7 @@ module LlmCostTracker
85
113
  def usage_rows(stats, component_costs: {})
86
114
  billable_tokens = stats.billable_tokens.to_f
87
115
 
88
- rows = Billing::Components::TOKEN_PRICED.map do |component|
116
+ rows = Usage::Catalog.token_priced.map do |component|
89
117
  token_value = stats[component.token_key].to_i
90
118
 
91
119
  {
@@ -118,7 +146,8 @@ module LlmCostTracker
118
146
  .where(unit: "token")
119
147
  .joins(:call)
120
148
  .merge(scope.unscope(:select, :order, :group))
121
- .group("#{line_item_table}.kind", "#{line_item_table}.direction",
149
+ .group("#{line_item_table}.kind",
150
+ "#{line_item_table}.direction",
122
151
  "#{line_item_table}.cache_state")
123
152
  .pluck(Arel.sql("#{line_item_table}.kind"),
124
153
  Arel.sql("#{line_item_table}.direction"),
@@ -130,7 +159,7 @@ module LlmCostTracker
130
159
  def streaming_health_rows(scope, total_streaming:)
131
160
  return [] unless total_streaming.positive?
132
161
 
133
- unknown_predicate = "usage_source = 'unknown' OR usage_source IS NULL"
162
+ unknown_predicate = unknown_usage_source_predicate(scope)
134
163
  rows = scope.unscope(:select, :order, :group)
135
164
  .where(stream: true)
136
165
  .group(:provider)
@@ -169,11 +198,7 @@ module LlmCostTracker
169
198
 
170
199
  def index_costs_by_component(rows)
171
200
  rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
172
- component = Billing::Components::TOKEN_PRICED.find do |item|
173
- item.kind.to_s == kind.to_s &&
174
- item.direction.to_s == direction.to_s &&
175
- item.cache_state.to_s == cache_state.to_s
176
- end
201
+ component = Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
177
202
  accumulator[component.key] = cost if component
178
203
  end
179
204
  end
@@ -185,14 +210,15 @@ module LlmCostTracker
185
210
  end
186
211
 
187
212
  def aggregate_selects(scope)
213
+ unknown_pricing = Charges::CostStatus.unknown_pricing_sql
188
214
  selects = [
189
215
  "COUNT(*) AS total_calls",
190
- "#{conditional_count_sql(unknown_pricing_predicate(scope))} AS unknown_pricing_count",
216
+ "#{conditional_count_sql(unknown_pricing)} AS unknown_pricing_count",
191
217
  "#{tagged_calls_sql(scope)} AS tagged_calls_count",
192
218
  "COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
193
219
  "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
194
220
  "#{conditional_count_sql('stream')} AS streaming_count",
195
- "#{streaming_missing_usage_select} AS streaming_missing_usage_count",
221
+ "#{streaming_missing_usage_select(scope)} AS streaming_missing_usage_count",
196
222
  "#{provider_response_id_select} AS missing_provider_response_id_count"
197
223
  ]
198
224
 
@@ -207,13 +233,13 @@ module LlmCostTracker
207
233
  end
208
234
 
209
235
  def usage_sum_columns
210
- Billing::Components::TOKEN_PRICED.map(&:token_key) + [:hidden_output_tokens]
236
+ Usage::Catalog.token_priced.map(&:token_key) + [:hidden_output_tokens]
211
237
  end
212
238
 
213
239
  def billable_tokens_select(scope)
214
- Billing::Components::TOKEN_PRICED
215
- .map { |component| column_sum(scope, component.token_key) }
216
- .join(" + ")
240
+ Usage::Catalog.token_priced
241
+ .map { |component| column_sum(scope, component.token_key) }
242
+ .join(" + ")
217
243
  end
218
244
 
219
245
  def hidden_output_share_select(scope)
@@ -223,13 +249,9 @@ module LlmCostTracker
223
249
  "CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
224
250
  end
225
251
 
226
- def unknown_pricing_predicate(scope)
227
- values = [
228
- LlmCostTracker::Billing::CostStatus::UNKNOWN,
229
- LlmCostTracker::Billing::CostStatus::PARTIAL
230
- ].map { |value| scope.connection.quote(value) }
231
-
232
- "total_cost IS NULL OR cost_status IN (#{values.join(', ')})"
252
+ def unknown_usage_source_predicate(scope)
253
+ quoted = scope.connection.quote(LlmCostTracker::Usage::Source::UNKNOWN)
254
+ "usage_source = #{quoted} OR usage_source IS NULL"
233
255
  end
234
256
 
235
257
  def column_sum(scope, column)
@@ -240,9 +262,8 @@ module LlmCostTracker
240
262
  "COALESCE(SUM(CASE WHEN #{predicate} THEN 1 ELSE 0 END), 0)"
241
263
  end
242
264
 
243
- def streaming_missing_usage_select
244
- predicate = "stream AND (usage_source = 'unknown' OR usage_source IS NULL)"
245
- conditional_count_sql(predicate)
265
+ def streaming_missing_usage_select(scope)
266
+ conditional_count_sql("stream AND (#{unknown_usage_source_predicate(scope)})")
246
267
  end
247
268
 
248
269
  def provider_response_id_select
@@ -5,6 +5,11 @@ require "date"
5
5
  module LlmCostTracker
6
6
  module Dashboard
7
7
  class Filter
8
+ STREAM_FILTER_OPTIONS = [
9
+ ["Streaming only", "yes"],
10
+ ["Non-streaming only", "no"]
11
+ ].freeze
12
+
8
13
  class << self
9
14
  def call(scope: LlmCostTracker::Call.all, params: {})
10
15
  new(scope: scope, params: params).relation
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module Masking
6
+ SENSITIVE_KEYS = %w[provider_api_key_id provider_workspace_id provider_project_id].freeze
7
+ MASK_TAIL_LENGTH = 4
8
+
9
+ def self.mask_value(key, value)
10
+ string = value.to_s
11
+ return string unless SENSITIVE_KEYS.include?(key.to_s)
12
+ return string if string.length <= MASK_TAIL_LENGTH
13
+
14
+ "***#{string[-MASK_TAIL_LENGTH, MASK_TAIL_LENGTH]}"
15
+ end
16
+
17
+ def self.mask_hash(hash)
18
+ return hash unless hash.is_a?(Hash)
19
+
20
+ hash.each_with_object({}) do |(key, value), masked|
21
+ masked[key] = case value
22
+ when Hash then mask_hash(value)
23
+ when Array then value.map { |entry| entry.is_a?(Hash) ? mask_hash(entry) : entry }
24
+ else
25
+ mask_value(key, value)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module MonthlyBudget
6
+ module_function
7
+
8
+ def status
9
+ budget = LlmCostTracker.configuration.monthly_budget
10
+ return nil unless budget
11
+
12
+ budget = budget.to_f
13
+ now = Time.now.utc
14
+ month_start = now.beginning_of_month
15
+ month_end = now.end_of_month
16
+ spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
17
+ elapsed_seconds = now - month_start
18
+ total_seconds = month_end - month_start
19
+ projected_spent = if spent.zero? || !elapsed_seconds.positive?
20
+ spent
21
+ else
22
+ spent * (total_seconds / elapsed_seconds)
23
+ end
24
+ percent_used = budget.positive? ? (spent / budget) * 100.0 : 0.0
25
+ projected_percent_used = budget.positive? ? (projected_spent / budget) * 100.0 : 0.0
26
+ projected_delta = projected_spent - budget
27
+
28
+ {
29
+ budget: budget,
30
+ spent: spent,
31
+ percent_used: percent_used,
32
+ projected_spent: projected_spent,
33
+ projected_percent_used: projected_percent_used,
34
+ projected_delta: projected_delta,
35
+ projection_end_label: month_end.strftime("%b %-d"),
36
+ fill_modifier: fill_modifier(percent_used),
37
+ progress_percent: clamped_percent(percent_used),
38
+ projected_marker_percent: clamped_percent(projected_percent_used),
39
+ projected_delta_amount: projected_delta.abs,
40
+ projected_delta_direction: projected_delta.positive? ? "over" : "under",
41
+ projected_delta_status_class: projected_delta_status_class(projected_delta)
42
+ }
43
+ end
44
+
45
+ def clamped_percent(value)
46
+ value.clamp(0.0, 100.0)
47
+ end
48
+
49
+ def fill_modifier(percent)
50
+ return "lct-budget-fill--over" if percent >= 100.0
51
+ return "lct-budget-fill--warn" if percent >= 80.0
52
+
53
+ ""
54
+ end
55
+
56
+ def projected_delta_status_class(delta)
57
+ return "lct-budget-projection-status--over" if delta.positive?
58
+
59
+ "lct-budget-projection-status--under"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/ledger"
4
-
5
3
  module LlmCostTracker
6
4
  module Dashboard
7
- class OverviewStats
5
+ module OverviewStats
8
6
  class << self
9
7
  def call(scope: LlmCostTracker::Call.all, previous_scope: nil)
10
8
  return scope.select(aggregate_selects).take unless previous_scope
@@ -16,48 +14,6 @@ module LlmCostTracker
16
14
  .take
17
15
  end
18
16
 
19
- def monthly_budget_status
20
- budget = LlmCostTracker.configuration.monthly_budget
21
- return nil unless budget
22
-
23
- budget = budget.to_f
24
- now = Time.now.utc
25
- month_start = now.beginning_of_month
26
- month_end = now.end_of_month
27
- spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
28
- elapsed_seconds = now - month_start
29
- total_seconds = month_end - month_start
30
- projected_spent = if spent.zero? || !elapsed_seconds.positive?
31
- spent
32
- else
33
- spent * (total_seconds / elapsed_seconds)
34
- end
35
- percent_used = budget.positive? ? (spent / budget) * 100.0 : 0.0
36
- projected_percent_used = budget.positive? ? (projected_spent / budget) * 100.0 : 0.0
37
- projected_delta = projected_spent - budget
38
-
39
- {
40
- budget: budget,
41
- spent: spent,
42
- percent_used: percent_used,
43
- projected_spent: projected_spent,
44
- projected_percent_used: projected_percent_used,
45
- projected_delta: projected_delta,
46
- projection_end_label: month_end.strftime("%b %-d"),
47
- fill_modifier: budget_fill_modifier(percent_used),
48
- progress_percent: clamped_percent(percent_used),
49
- projected_marker_percent: clamped_percent(projected_percent_used),
50
- projected_delta_amount: projected_delta.abs,
51
- projected_delta_direction: projected_delta.positive? ? "over" : "under",
52
- projected_delta_status_class: projected_delta_status_class(projected_delta)
53
- }
54
- end
55
-
56
- UNKNOWN_PRICING_COST_STATUSES = [
57
- LlmCostTracker::Billing::CostStatus::UNKNOWN,
58
- LlmCostTracker::Billing::CostStatus::PARTIAL
59
- ].freeze
60
-
61
17
  private
62
18
 
63
19
  def aggregate_selects(table_name: nil, previous: false)
@@ -69,11 +25,10 @@ module LlmCostTracker
69
25
  THEN COALESCE(SUM(#{total_cost}), 0) * 1.0 / COUNT(*)
70
26
  ELSE 0 END
71
27
  SQL
72
- unknown_pricing_sql = <<~SQL.squish
73
- SUM(CASE WHEN #{total_cost} IS NULL OR
74
- #{cost_status} IN (#{UNKNOWN_PRICING_COST_STATUSES.map { |s| connection.quote(s) }.join(', ')})
75
- THEN 1 ELSE 0 END)
76
- SQL
28
+ predicate = LlmCostTracker::Charges::CostStatus.unknown_pricing_sql(
29
+ total_cost: total_cost, cost_status: cost_status
30
+ )
31
+ unknown_pricing_sql = "SUM(CASE WHEN #{predicate} THEN 1 ELSE 0 END)"
77
32
  selects = [
78
33
  "COUNT(*) AS total_calls",
79
34
  "COALESCE(SUM(#{total_cost}), 0) AS total_cost",
@@ -85,10 +40,6 @@ module LlmCostTracker
85
40
  selects.join(", ")
86
41
  end
87
42
 
88
- def connection
89
- LlmCostTracker::Call.connection
90
- end
91
-
92
43
  def previous_selects(previous)
93
44
  unless previous
94
45
  return [
@@ -125,23 +76,6 @@ module LlmCostTracker
125
76
  .select("COALESCE(SUM(total_cost), 0) AS total_cost", "COUNT(*) AS total_calls")
126
77
  .to_sql
127
78
  end
128
-
129
- def clamped_percent(value)
130
- value.clamp(0.0, 100.0)
131
- end
132
-
133
- def budget_fill_modifier(percent)
134
- return "lct-budget-fill--over" if percent >= 100.0
135
- return "lct-budget-fill--warn" if percent >= 80.0
136
-
137
- ""
138
- end
139
-
140
- def projected_delta_status_class(delta)
141
- return "lct-budget-projection-status--over" if delta.positive?
142
-
143
- "lct-budget-projection-status--under"
144
- end
145
79
  end
146
80
  end
147
81
  end
@@ -6,6 +6,7 @@ module LlmCostTracker
6
6
  DEFAULT_PER = 50
7
7
  MAX_PER = 200
8
8
  MIN_PAGE = 1
9
+ MIN_PER = 1
9
10
 
10
11
  attr_reader :page, :per
11
12
 
@@ -13,7 +14,7 @@ module LlmCostTracker
13
14
  params = Params.to_hash(params).symbolize_keys
14
15
  new(
15
16
  page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
16
- per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
17
+ per: integer_param(params, :per, default: DEFAULT_PER, min: MIN_PER, max: MAX_PER)
17
18
  )
18
19
  end
19
20
 
@@ -33,10 +34,6 @@ module LlmCostTracker
33
34
  freeze
34
35
  end
35
36
 
36
- def limit
37
- per
38
- end
39
-
40
37
  def offset
41
38
  (page - 1) * per
42
39
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ class PricingOverview
6
+ SOURCES = %i[overrides file bundled].freeze
7
+ RATE_COLUMNS = %w[input output cache_read_input cache_write_input batch_input batch_output].freeze
8
+ Row = Data.define(:provider, :model, :rates)
9
+
10
+ SOURCE_NAME = { overrides: "pricing_overrides", file: "prices_file", bundled: "bundled" }.freeze
11
+ LABEL = { overrides: "Overrides", file: "Custom file", bundled: "Bundled" }.freeze
12
+ private_constant :SOURCE_NAME, :LABEL
13
+
14
+ class << self
15
+ def call
16
+ new.call
17
+ end
18
+ end
19
+
20
+ def call
21
+ sources = SOURCES.each_with_object({}) do |key, acc|
22
+ source = sources_by_name.fetch(SOURCE_NAME.fetch(key))
23
+ acc[key] = present(key, source) unless source.prices.empty?
24
+ end
25
+ {
26
+ sources: sources,
27
+ effective_source: sources.keys.first || :bundled
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def sources_by_name
34
+ @sources_by_name ||= Pricing::Registry.sources.to_h { |source| [source.name, source] }
35
+ end
36
+
37
+ def present(key, source)
38
+ {
39
+ label: LABEL.fetch(key),
40
+ subtitle: subtitle_for(key),
41
+ updated_at: updated_at_for(key),
42
+ currency: source.currency,
43
+ rows: build_rows(source.prices)
44
+ }
45
+ end
46
+
47
+ def subtitle_for(key)
48
+ case key
49
+ when :overrides then "config.pricing_overrides"
50
+ when :file then LlmCostTracker.configuration.prices_file.to_s
51
+ when :bundled then "ships with the gem"
52
+ end
53
+ end
54
+
55
+ def updated_at_for(key)
56
+ case key
57
+ when :file
58
+ path = LlmCostTracker.configuration.prices_file
59
+ Pricing::Registry.file_metadata(path)["updated_at"] || Pricing::Registry.prices_file_mtime_iso
60
+ when :bundled
61
+ Pricing::Registry.metadata["updated_at"]
62
+ end
63
+ end
64
+
65
+ def build_rows(prices)
66
+ rows = prices.map do |key, rates|
67
+ provider, model = split_key(key.to_s)
68
+ Row.new(provider: provider, model: model, rates: rates)
69
+ end
70
+ rows.sort_by { |row| [row.provider || "~", row.model] }
71
+ end
72
+
73
+ def split_key(key)
74
+ provider, model = key.split("/", 2)
75
+ return [provider, model] if model
76
+
77
+ [nil, provider]
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,53 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/ledger/schema/calls"
4
- require "llm_cost_tracker/ledger/schema/call_line_items"
5
- require "llm_cost_tracker/ledger/schema/call_tags"
6
- require "llm_cost_tracker/ledger/schema/call_rollups"
7
-
8
3
  module LlmCostTracker
9
4
  module Dashboard
10
5
  module SetupState
11
6
  SetupRequired = Data.define(:message, :details)
12
- DOCS_HINT = "See docs/upgrading.md for the migration path."
13
- MUTEX = Mutex.new
14
-
15
- CORE_SCHEMA_CHECKS = [
16
- [
17
- LlmCostTracker::Ledger::Schema::Calls,
18
- "The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
19
- ],
20
- [
21
- LlmCostTracker::Ledger::Schema::CallLineItems,
22
- "The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
23
- ],
24
- [
25
- LlmCostTracker::Ledger::Schema::CallTags,
26
- "The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
27
- ]
28
- ].freeze
29
-
30
- OPTIONAL_CALL_ROLLUPS_CHECK = [
31
- LlmCostTracker::Ledger::Schema::CallRollups,
32
- "The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
33
- ].freeze
34
-
35
- private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
36
7
 
37
8
  class << self
38
9
  def current
39
- return @cached if defined?(@cached)
10
+ return @current if defined?(@current)
40
11
 
41
- MUTEX.synchronize do
42
- @cached = compute unless defined?(@cached)
43
- end
44
- @cached
12
+ @current = compute
45
13
  end
46
14
 
47
15
  def reset!
48
- MUTEX.synchronize do
49
- remove_instance_variable(:@cached) if defined?(@cached)
50
- end
16
+ remove_instance_variable(:@current) if defined?(@current)
51
17
  end
52
18
 
53
19
  private
@@ -56,44 +22,16 @@ module LlmCostTracker
56
22
  LlmCostTracker::Logging.debug("Dashboard::SetupState recomputing")
57
23
  return calls_table_missing unless LlmCostTracker::Call.table_exists?
58
24
 
59
- core_drift = drift_in(schema_checks_for_current_config)
60
- return core_drift if core_drift
61
- return nil unless LlmCostTracker.reconciliation_enabled?
62
-
63
- reconciliation_drift
64
- end
65
-
66
- def schema_checks_for_current_config
67
- return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
68
-
69
- CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
25
+ drift_in(LlmCostTracker::Ingestion.guards_for_current_config)
70
26
  end
71
27
 
72
28
  def drift_in(checks)
73
- checks.each do |schema, message|
74
- errors = schema.current_schema_errors
75
- next if errors.empty?
76
-
77
- return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
78
- end
79
- nil
80
- end
81
-
82
- def reconciliation_drift
83
- connection = ActiveRecord::Base.connection
84
- LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
85
- unless connection.data_source_exists?(table)
86
- return SetupRequired.new(
87
- message: "The #{table} table is required when reconciliation is enabled.",
88
- details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
89
- )
90
- end
91
-
29
+ checks.each do |schema, table|
92
30
  errors = schema.current_schema_errors
93
31
  next if errors.empty?
94
32
 
95
33
  message = "The #{table} table does not match the current LLM Cost Tracker schema."
96
- return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
34
+ return SetupRequired.new(message: message, details: errors)
97
35
  end
98
36
  nil
99
37
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module Sort
6
+ DIRECTIONS = %w[asc desc].freeze
7
+ end
8
+ end
9
+ end