llm_cost_tracker 0.11.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. 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
@@ -4,9 +4,13 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  class PricingOverview
6
6
  SOURCES = %i[overrides file bundled].freeze
7
- RATE_COLUMNS = %i[input output cache_read_input cache_write_input batch_input batch_output].freeze
7
+ RATE_COLUMNS = %w[input output cache_read_input cache_write_input batch_input batch_output].freeze
8
8
  Row = Data.define(:provider, :model, :rates)
9
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
+
10
14
  class << self
11
15
  def call
12
16
  new.call
@@ -14,9 +18,9 @@ module LlmCostTracker
14
18
  end
15
19
 
16
20
  def call
17
- sources = SOURCES.each_with_object({}) do |source, acc|
18
- built = build_source(source)
19
- acc[source] = built if built
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?
20
24
  end
21
25
  {
22
26
  sources: sources,
@@ -26,54 +30,36 @@ module LlmCostTracker
26
30
 
27
31
  private
28
32
 
29
- def build_source(source)
30
- case source
31
- when :overrides then build_overrides
32
- when :file then build_file
33
- when :bundled then build_bundled
34
- end
33
+ def sources_by_name
34
+ @sources_by_name ||= Pricing::Registry.sources.to_h { |source| [source.name, source] }
35
35
  end
36
36
 
37
- def build_overrides
38
- prices = LlmCostTracker.configuration.pricing_overrides
39
- return nil if prices.nil? || prices.empty?
40
-
37
+ def present(key, source)
41
38
  {
42
- label: "Overrides",
43
- subtitle: "config.pricing_overrides",
44
- updated_at: nil,
45
- currency: nil,
46
- rows: build_rows(prices)
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)
47
44
  }
48
45
  end
49
46
 
50
- def build_file
51
- path = LlmCostTracker.configuration.prices_file
52
- return nil unless path && File.exist?(path)
53
-
54
- prices = Pricing::Registry.file_prices(path)
55
- return nil if prices.empty?
56
-
57
- meta = Pricing::Registry.file_metadata(path)
58
- {
59
- label: "Custom file",
60
- subtitle: path.to_s,
61
- updated_at: meta["updated_at"] || Pricing::Lookup.prices_file_mtime_iso,
62
- currency: meta["currency"] || Pricing::Lookup::DEFAULT_CURRENCY,
63
- rows: build_rows(prices)
64
- }
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
65
53
  end
66
54
 
67
- def build_bundled
68
- prices = Pricing::Registry.builtin_prices
69
- meta = Pricing::Registry.metadata
70
- {
71
- label: "Bundled",
72
- subtitle: "ships with the gem",
73
- updated_at: meta["updated_at"],
74
- currency: meta["currency"] || Pricing::Lookup::DEFAULT_CURRENCY,
75
- rows: build_rows(prices)
76
- }
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
77
63
  end
78
64
 
79
65
  def build_rows(prices)
@@ -1,65 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/ledger"
4
-
5
3
  module LlmCostTracker
6
4
  module Dashboard
7
5
  module SetupState
8
6
  SetupRequired = Data.define(:message, :details)
9
- DOCS_HINT = "See docs/upgrading.md for the migration path."
10
- MUTEX = Mutex.new
11
-
12
- private_constant :MUTEX, :DOCS_HINT
13
7
 
14
8
  class << self
15
9
  def current
16
- fingerprint = schema_fingerprint
10
+ return @current if defined?(@current)
17
11
 
18
- MUTEX.synchronize do
19
- if !defined?(@cache_fingerprint) || @cache_fingerprint != fingerprint
20
- LlmCostTracker::Call.reset_column_information
21
- @cached = compute
22
- @cache_fingerprint = fingerprint
23
- end
24
- end
25
- @cached
12
+ @current = compute
26
13
  end
27
14
 
28
15
  def reset!
29
- MUTEX.synchronize do
30
- remove_instance_variable(:@cached) if defined?(@cached)
31
- remove_instance_variable(:@cache_fingerprint) if defined?(@cache_fingerprint)
32
- end
16
+ remove_instance_variable(:@current) if defined?(@current)
33
17
  end
34
18
 
35
19
  private
36
20
 
37
- SCHEMA_MIGRATIONS_TABLE = "schema_migrations"
38
- private_constant :SCHEMA_MIGRATIONS_TABLE
39
-
40
- def schema_fingerprint
41
- connection = ActiveRecord::Base.connection
42
- quoted = connection.quote_table_name(SCHEMA_MIGRATIONS_TABLE)
43
- connection.query_value("SELECT MAX(version) FROM #{quoted}")
44
- rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
45
- nil
46
- end
47
-
48
21
  def compute
49
22
  LlmCostTracker::Logging.debug("Dashboard::SetupState recomputing")
50
23
  return calls_table_missing unless LlmCostTracker::Call.table_exists?
51
24
 
52
- core_drift = drift_in(schema_checks_for_current_config)
53
- return core_drift if core_drift
54
- return nil unless LlmCostTracker.reconciliation_enabled?
55
-
56
- reconciliation_drift
57
- end
58
-
59
- def schema_checks_for_current_config
60
- return LlmCostTracker::Ledger::Schema::CORE_SCHEMAS unless LlmCostTracker.configuration.cache_rollups
61
-
62
- LlmCostTracker::Ledger::Schema::CORE_SCHEMAS + [LlmCostTracker::Ledger::Schema::CACHE_ROLLUPS_SCHEMA]
25
+ drift_in(LlmCostTracker::Ingestion.guards_for_current_config)
63
26
  end
64
27
 
65
28
  def drift_in(checks)
@@ -73,25 +36,6 @@ module LlmCostTracker
73
36
  nil
74
37
  end
75
38
 
76
- def reconciliation_drift
77
- connection = ActiveRecord::Base.connection
78
- LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
79
- unless connection.data_source_exists?(table)
80
- return SetupRequired.new(
81
- message: "The #{table} table is required when reconciliation is enabled.",
82
- details: ["bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate"]
83
- )
84
- end
85
-
86
- errors = schema.current_schema_errors
87
- next if errors.empty?
88
-
89
- message = "The #{table} table does not match the current LLM Cost Tracker schema."
90
- return SetupRequired.new(message: message, details: errors)
91
- end
92
- nil
93
- end
94
-
95
39
  def calls_table_missing
96
40
  SetupRequired.new(
97
41
  message: "The llm_cost_tracker_calls table is not available yet.",
@@ -35,7 +35,7 @@ module LlmCostTracker
35
35
  calls: calls,
36
36
  total_cost: row.total_cost,
37
37
  average_cost_per_call: row.average_cost_per_call,
38
- share_percent: percentage(calls, total)
38
+ share_percent: total.positive? ? (calls.to_f / total) * 100.0 : 0.0
39
39
  )
40
40
  end
41
41
  end
@@ -115,12 +115,6 @@ module LlmCostTracker
115
115
  def quoted_key
116
116
  scope.connection.quote(key)
117
117
  end
118
-
119
- def percentage(numerator, denominator)
120
- return 0.0 unless denominator.positive?
121
-
122
- (numerator / denominator.to_f) * 100.0
123
- end
124
118
  end
125
119
  end
126
120
  end
@@ -20,7 +20,7 @@ module LlmCostTracker
20
20
 
21
21
  def rows
22
22
  scope.klass.find_by_sql(build_sql)
23
- rescue StandardError => e
23
+ rescue ActiveRecord::StatementInvalid => e
24
24
  LlmCostTracker::Logging.warn("Tag key discovery failed (#{connection.adapter_name}): #{e.class}: #{e.message}")
25
25
  []
26
26
  end