llm_cost_tracker 0.7.2 → 0.8.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -1,45 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/pricing"
3
+ require "llm_cost_tracker/billing/components"
4
4
  require "llm_cost_tracker/ledger/schema/adapter"
5
5
 
6
6
  module LlmCostTracker
7
7
  module Dashboard
8
8
  class DataQuality
9
+ UnknownPricingRow = ::Data.define(:model, :calls, :share_percent)
10
+ Summary = ::Data.define(:total, :unknown_pricing_count, :untagged_calls_count, :missing_latency_count,
11
+ :streaming_count, :streaming_missing_usage, :missing_provider_response_id_count,
12
+ :calls_with_pricing, :tagged_calls, :calls_with_latency, :streams_with_usage,
13
+ :calls_with_provider_response_id, :unknown_pricing_share, :untagged_share,
14
+ :missing_latency_share, :streaming_share, :streaming_missing_usage_share,
15
+ :cost_coverage, :tag_coverage, :latency_coverage, :stream_coverage,
16
+ :provider_response_id_coverage)
17
+
9
18
  class << self
10
- def call(scope: LlmCostTracker::Ledger::Call.all)
11
- model = scope.klass
12
- scope.unscope(:order).select(aggregate_selects(scope, model:)).take
19
+ def call(scope: LlmCostTracker::Call.all)
20
+ scope.unscope(:order).select(aggregate_selects(scope)).take
21
+ end
22
+
23
+ def summary(stats)
24
+ total = stats.total_calls.to_i
25
+ unknown_pricing_count = stats.unknown_pricing_count.to_i
26
+ untagged_calls_count = stats.untagged_calls_count.to_i
27
+ missing_latency_count = stats.missing_latency_count.to_i
28
+ streaming_count = stats.streaming_count.to_i
29
+ streaming_missing_usage = stats.streaming_missing_usage_count.to_i
30
+ missing_provider_response_id_count = stats.missing_provider_response_id_count.to_i
31
+ calls_with_pricing = total - unknown_pricing_count
32
+ tagged_calls = total - untagged_calls_count
33
+ calls_with_latency = total - missing_latency_count
34
+ streams_with_usage = streaming_count - streaming_missing_usage
35
+ calls_with_provider_response_id = total - missing_provider_response_id_count
36
+
37
+ Summary.new(
38
+ total, unknown_pricing_count, untagged_calls_count, missing_latency_count, streaming_count,
39
+ streaming_missing_usage, missing_provider_response_id_count, calls_with_pricing, tagged_calls,
40
+ calls_with_latency, streams_with_usage, calls_with_provider_response_id,
41
+ percentage(unknown_pricing_count, total), percentage(untagged_calls_count, total),
42
+ percentage(missing_latency_count, total), percentage(streaming_count, total),
43
+ percentage(streaming_missing_usage, streaming_count), percentage(calls_with_pricing, total),
44
+ percentage(tagged_calls, total), percentage(calls_with_latency, total),
45
+ percentage(streams_with_usage, streaming_count), percentage(calls_with_provider_response_id, total)
46
+ )
13
47
  end
14
48
 
15
- def unknown_pricing_by_model(scope)
49
+ def unknown_pricing_by_model(scope, total_calls:)
16
50
  scope.unknown_pricing
17
51
  .group(:model)
18
52
  .order(Arel.sql("COUNT(*) DESC"))
19
53
  .select("model, COUNT(*) AS calls")
20
54
  .limit(10)
55
+ .map do |row|
56
+ calls = row.calls.to_i
57
+ UnknownPricingRow.new(model: row.model, calls: calls, share_percent: percentage(calls, total_calls))
58
+ end
59
+ end
60
+
61
+ def service_charge_rows(scope)
62
+ call_table = LlmCostTracker::Call.quoted_table_name
63
+ line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
64
+ relation = LlmCostTracker::CallLineItem
65
+ .where.not(unit: "token")
66
+ .joins(:call)
67
+ .merge(scope.unscope(:select, :order))
68
+
69
+ relation
70
+ .group("#{call_table}.provider", "#{line_item_table}.kind", "#{line_item_table}.cost_status")
71
+ .order(Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0) DESC"), Arel.sql("COUNT(*) DESC"))
72
+ .select(
73
+ "#{call_table}.provider AS provider",
74
+ "#{line_item_table}.kind AS component",
75
+ "#{line_item_table}.cost_status AS cost_status",
76
+ "COUNT(*) AS charges_count",
77
+ "COALESCE(SUM(#{line_item_table}.quantity), 0) AS quantity",
78
+ "COALESCE(SUM(#{line_item_table}.cost), 0) AS total_cost"
79
+ )
80
+ .limit(10)
21
81
  end
22
82
 
23
- def usage_rows(stats)
83
+ def usage_rows(stats, component_costs: {})
24
84
  billable_tokens = stats.billable_tokens.to_f
25
85
 
26
- rows = Pricing::COMPONENTS.map do |component|
27
- token_key = component.token_key
28
- cost_key = component.cost_key
29
- token_value = stats[token_key].to_i
30
- share_percent = if billable_tokens.positive?
31
- (token_value.to_f / billable_tokens) * 100.0
32
- else
33
- 0.0
34
- end
86
+ rows = Billing::Components::TOKEN_PRICED.map do |component|
87
+ token_value = stats[component.token_key].to_i
35
88
 
36
89
  {
37
- price_key: component.price_key,
38
- token_key: token_key,
39
- cost_key: cost_key,
90
+ price_key: component.key,
91
+ token_key: component.token_key,
92
+ cost_key: component.cost_key,
40
93
  token_value: token_value,
41
- cost_value: stats[cost_key],
42
- share_percent: share_percent,
94
+ cost_value: component_costs[component.key],
95
+ share_percent: percentage(token_value, billable_tokens),
43
96
  share_basis: nil
44
97
  }
45
98
  end
@@ -57,6 +110,21 @@ module LlmCostTracker
57
110
  ]
58
111
  end
59
112
 
113
+ def component_costs(scope)
114
+ line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
115
+ rows = LlmCostTracker::CallLineItem
116
+ .where(unit: "token")
117
+ .joins(:call)
118
+ .merge(scope.unscope(:select, :order, :group))
119
+ .group("#{line_item_table}.kind", "#{line_item_table}.direction",
120
+ "#{line_item_table}.cache_state")
121
+ .pluck(Arel.sql("#{line_item_table}.kind"),
122
+ Arel.sql("#{line_item_table}.direction"),
123
+ Arel.sql("#{line_item_table}.cache_state"),
124
+ Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
125
+ index_costs_by_component(rows)
126
+ end
127
+
60
128
  def hidden_output_summary(stats)
61
129
  output_tokens = stats.output_tokens.to_i
62
130
  return unless output_tokens.positive?
@@ -70,12 +138,29 @@ module LlmCostTracker
70
138
 
71
139
  private
72
140
 
73
- def aggregate_selects(scope, model:)
141
+ def index_costs_by_component(rows)
142
+ rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
143
+ component = Billing::Components::TOKEN_PRICED.find do |item|
144
+ item.kind.to_s == kind.to_s &&
145
+ item.direction.to_s == direction.to_s &&
146
+ item.cache_state.to_s == cache_state.to_s
147
+ end
148
+ accumulator[component.key] = cost if component
149
+ end
150
+ end
151
+
152
+ def percentage(numerator, denominator)
153
+ return 0.0 unless denominator.positive?
154
+
155
+ (numerator.to_f / denominator) * 100.0
156
+ end
157
+
158
+ def aggregate_selects(scope)
74
159
  selects = [
75
160
  "COUNT(*) AS total_calls",
76
- "#{conditional_count_sql('total_cost IS NULL')} AS unknown_pricing_count",
77
- "#{tagged_calls_sql(model)} AS tagged_calls_count",
78
- "COUNT(*) - #{tagged_calls_sql(model)} AS untagged_calls_count",
161
+ "#{conditional_count_sql(unknown_pricing_predicate(scope))} AS unknown_pricing_count",
162
+ "#{tagged_calls_sql(scope)} AS tagged_calls_count",
163
+ "COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
79
164
  "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
80
165
  "#{conditional_count_sql('stream')} AS streaming_count",
81
166
  "#{streaming_missing_usage_select} AS streaming_missing_usage_count",
@@ -93,11 +178,11 @@ module LlmCostTracker
93
178
  end
94
179
 
95
180
  def usage_sum_columns
96
- Pricing::COMPONENTS.map(&:token_key) + [:hidden_output_tokens] + Pricing::COMPONENTS.map(&:cost_key)
181
+ Billing::Components::TOKEN_PRICED.map(&:token_key) + [:hidden_output_tokens]
97
182
  end
98
183
 
99
184
  def billable_tokens_select(scope)
100
- Pricing::COMPONENTS
185
+ Billing::Components::TOKEN_PRICED
101
186
  .map { |component| column_sum(scope, component.token_key) }
102
187
  .join(" + ")
103
188
  end
@@ -109,6 +194,15 @@ module LlmCostTracker
109
194
  "CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
110
195
  end
111
196
 
197
+ def unknown_pricing_predicate(scope)
198
+ values = [
199
+ LlmCostTracker::Billing::CostStatus::UNKNOWN,
200
+ LlmCostTracker::Billing::CostStatus::PARTIAL
201
+ ].map { |value| scope.connection.quote(value) }
202
+
203
+ "total_cost IS NULL OR cost_status IN (#{values.join(', ')})"
204
+ end
205
+
112
206
  def column_sum(scope, column)
113
207
  "COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)"
114
208
  end
@@ -127,15 +221,12 @@ module LlmCostTracker
127
221
  conditional_count_sql(predicate)
128
222
  end
129
223
 
130
- def tagged_calls_sql(model)
131
- table = model.quoted_table_name
132
- column = "#{table}.#{model.connection.quote_column_name('tags')}"
224
+ def tagged_calls_sql(scope)
225
+ calls_table = scope.klass.quoted_table_name
226
+ tags_table = LlmCostTracker::CallTag.quoted_table_name
133
227
 
134
- if Ledger::Schema::Adapter.postgresql?(model.connection)
135
- "COALESCE(SUM(CASE WHEN #{column} <> '{}'::jsonb THEN 1 ELSE 0 END), 0)"
136
- else
137
- "COALESCE(SUM(CASE WHEN JSON_LENGTH(#{column}) > 0 THEN 1 ELSE 0 END), 0)"
138
- end
228
+ "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
229
+ "WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id) THEN 1 ELSE 0 END), 0)"
139
230
  end
140
231
  end
141
232
  end
@@ -13,7 +13,7 @@ module LlmCostTracker
13
13
  end
14
14
 
15
15
  def self.parse(params, key)
16
- value = LlmCostTracker::Dashboard::Params.with_indifferent_access(params)[key].to_s.strip.presence
16
+ value = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys[key].to_s.strip.presence
17
17
  return nil unless value
18
18
 
19
19
  Date.iso8601(value)
@@ -6,14 +6,14 @@ module LlmCostTracker
6
6
  module Dashboard
7
7
  class Filter
8
8
  class << self
9
- def call(scope: LlmCostTracker::Ledger::Call.all, params: {})
9
+ def call(scope: LlmCostTracker::Call.all, params: {})
10
10
  new(scope: scope, params: params).relation
11
11
  end
12
12
  end
13
13
 
14
14
  def initialize(scope:, params:)
15
15
  @scope = scope
16
- @params = LlmCostTracker::Dashboard::Params.with_indifferent_access(params)
16
+ @params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
17
17
  end
18
18
 
19
19
  def relation
@@ -6,18 +6,25 @@ module LlmCostTracker
6
6
  module Dashboard
7
7
  class OverviewStats
8
8
  class << self
9
- def call(scope: LlmCostTracker::Ledger::Call.all, previous_scope: nil)
10
- scope.select(aggregate_selects(previous_scope: previous_scope)).take
9
+ def call(scope: LlmCostTracker::Call.all, previous_scope: nil)
10
+ return scope.select(aggregate_selects).take unless previous_scope
11
+
12
+ scope.klass
13
+ .from("(#{scope.unscope(:select, :order).to_sql}) AS current_calls")
14
+ .joins("CROSS JOIN (#{previous_aggregate_sql(previous_scope)}) AS previous_stats")
15
+ .select(aggregate_selects(table_name: "current_calls", previous: true))
16
+ .take
11
17
  end
12
18
 
13
19
  def monthly_budget_status
14
20
  budget = LlmCostTracker.configuration.monthly_budget
15
21
  return nil unless budget
16
22
 
23
+ budget = budget.to_f
17
24
  now = Time.now.utc
18
25
  month_start = now.beginning_of_month
19
26
  month_end = now.end_of_month
20
- spent = LlmCostTracker::Ledger::Period::Totals.call(%i[monthly], time: now).fetch(:monthly)
27
+ spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
21
28
  elapsed_seconds = now - month_start
22
29
  total_seconds = month_end - month_start
23
30
  projected_spent = if spent.zero? || !elapsed_seconds.positive?
@@ -25,39 +32,65 @@ module LlmCostTracker
25
32
  else
26
33
  spent * (total_seconds / elapsed_seconds)
27
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
28
38
 
29
39
  {
30
- budget: budget.to_f,
40
+ budget: budget,
31
41
  spent: spent,
32
- percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0,
42
+ percent_used: percent_used,
33
43
  projected_spent: projected_spent,
34
- projected_percent_used: budget.to_f.positive? ? (projected_spent / budget.to_f) * 100.0 : 0.0,
35
- projected_delta: projected_spent - budget.to_f,
36
- projection_end_label: month_end.strftime("%b %-d")
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)
37
53
  }
38
54
  end
39
55
 
56
+ UNKNOWN_PRICING_COST_STATUSES = [
57
+ LlmCostTracker::Billing::CostStatus::UNKNOWN,
58
+ LlmCostTracker::Billing::CostStatus::PARTIAL
59
+ ].freeze
60
+
40
61
  private
41
62
 
42
- def aggregate_selects(previous_scope:)
63
+ def aggregate_selects(table_name: nil, previous: false)
64
+ total_cost = table_name ? "#{table_name}.total_cost" : "total_cost"
65
+ latency_ms = table_name ? "#{table_name}.latency_ms" : "latency_ms"
66
+ cost_status = table_name ? "#{table_name}.cost_status" : "cost_status"
43
67
  average_cost_sql = <<~SQL.squish
44
68
  CASE WHEN COUNT(*) > 0
45
- THEN COALESCE(SUM(total_cost), 0) * 1.0 / COUNT(*)
69
+ THEN COALESCE(SUM(#{total_cost}), 0) * 1.0 / COUNT(*)
46
70
  ELSE 0 END
47
71
  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
48
77
  selects = [
49
78
  "COUNT(*) AS total_calls",
50
- "COALESCE(SUM(total_cost), 0) AS total_cost",
79
+ "COALESCE(SUM(#{total_cost}), 0) AS total_cost",
51
80
  "#{average_cost_sql} AS average_cost_per_call",
52
- "SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END) AS unknown_pricing_count",
53
- "AVG(latency_ms) AS average_latency_ms"
81
+ "#{unknown_pricing_sql} AS unknown_pricing_count",
82
+ "AVG(#{latency_ms}) AS average_latency_ms"
54
83
  ]
55
- selects.concat(previous_selects(previous_scope))
84
+ selects.concat(previous_selects(previous))
56
85
  selects.join(", ")
57
86
  end
58
87
 
59
- def previous_selects(previous_scope)
60
- unless previous_scope
88
+ def connection
89
+ LlmCostTracker::Call.connection
90
+ end
91
+
92
+ def previous_selects(previous)
93
+ unless previous
61
94
  return [
62
95
  "NULL AS previous_total_cost",
63
96
  "NULL AS previous_total_calls",
@@ -66,11 +99,11 @@ module LlmCostTracker
66
99
  ]
67
100
  end
68
101
 
69
- previous_cost_sql = aggregate_subquery(previous_scope, "COALESCE(SUM(total_cost), 0)")
70
- previous_calls_sql = aggregate_subquery(previous_scope, "COUNT(*)")
102
+ previous_cost_sql = "MAX(previous_stats.total_cost)"
103
+ previous_calls_sql = "MAX(previous_stats.total_calls)"
71
104
  cost_delta_sql = <<~SQL.squish
72
105
  CASE WHEN (#{previous_cost_sql}) = 0 THEN NULL
73
- ELSE ((COALESCE(SUM(total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
106
+ ELSE ((COALESCE(SUM(current_calls.total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
74
107
  END
75
108
  SQL
76
109
  calls_delta_sql = <<~SQL.squish
@@ -86,8 +119,28 @@ module LlmCostTracker
86
119
  ]
87
120
  end
88
121
 
89
- def aggregate_subquery(scope, expression)
90
- scope.unscope(:select, :order).select(expression).to_sql
122
+ def previous_aggregate_sql(scope)
123
+ scope
124
+ .unscope(:select, :order)
125
+ .select("COALESCE(SUM(total_cost), 0) AS total_cost", "COUNT(*) AS total_calls")
126
+ .to_sql
127
+ 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"
91
144
  end
92
145
  end
93
146
  end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
  attr_reader :page, :per
11
11
 
12
12
  def self.call(params)
13
- params = Params.with_indifferent_access(params)
13
+ params = Params.to_hash(params).symbolize_keys
14
14
  new(
15
15
  page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
16
16
  per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
@@ -46,13 +46,15 @@ module LlmCostTracker
46
46
  end
47
47
 
48
48
  def next_page?(total_count)
49
- offset + per < total_count.to_i
49
+ total_count = total_count.to_i
50
+ offset + per < total_count
50
51
  end
51
52
 
52
53
  def total_pages(total_count)
53
- return MIN_PAGE if total_count.to_i <= 0
54
+ total_count = total_count.to_i
55
+ return MIN_PAGE unless total_count.positive?
54
56
 
55
- [(total_count.to_f / per).ceil, MIN_PAGE].max
57
+ ((total_count + per - 1) / per)
56
58
  end
57
59
  end
58
60
  end
@@ -17,8 +17,14 @@ module LlmCostTracker
17
17
  {}
18
18
  end
19
19
 
20
- def with_indifferent_access(value)
21
- to_hash(value).with_indifferent_access
20
+ def tag_query(value)
21
+ to_hash(value).each_with_object({}) do |(key, tag_value), tags|
22
+ key = key.to_s
23
+ tag_value = tag_value.to_s
24
+ next if key.blank? || tag_value.blank?
25
+
26
+ tags[key] = tag_value
27
+ end
22
28
  end
23
29
  end
24
30
  end
@@ -3,7 +3,7 @@
3
3
  module LlmCostTracker
4
4
  module Dashboard
5
5
  class ProviderBreakdown
6
- def self.call(scope: LlmCostTracker::Ledger::Call.all)
6
+ def self.call(scope: LlmCostTracker::Call.all)
7
7
  new(scope: scope).rows
8
8
  end
9
9
 
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  WINDOW_DAYS = 7
7
7
 
8
8
  class << self
9
- def call(from:, to:, scope: LlmCostTracker::Ledger::Call.all)
9
+ def call(from:, to:, scope: LlmCostTracker::Call.all)
10
10
  new(scope: scope, from: from, to: to).alert
11
11
  end
12
12
  end
@@ -28,13 +28,14 @@ module LlmCostTracker
28
28
  attr_reader :scope, :from, :to
29
29
 
30
30
  def alerts
31
+ window_days = WINDOW_DAYS.to_f
31
32
  daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
32
33
  latest_spend = daily_costs.fetch(to, 0.0)
33
34
  next unless latest_spend.positive?
34
35
 
35
36
  baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
36
- mean = baseline_days.sum / WINDOW_DAYS.to_f
37
- variance = baseline_days.sum { |value| (value - mean)**2 } / WINDOW_DAYS.to_f
37
+ mean = baseline_days.sum / window_days
38
+ variance = baseline_days.sum { |value| (value - mean)**2 } / window_days
38
39
  threshold = mean + (2 * Math.sqrt(variance))
39
40
  next unless latest_spend > threshold
40
41
 
@@ -4,9 +4,10 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  class TagBreakdown
6
6
  DEFAULT_LIMIT = 100
7
+ Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
7
8
 
8
9
  class << self
9
- def call(key:, scope: LlmCostTracker::Ledger::Call.all, limit: DEFAULT_LIMIT)
10
+ def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
10
11
  new(scope: scope, key: key, limit: limit)
11
12
  end
12
13
  end
@@ -21,7 +22,19 @@ module LlmCostTracker
21
22
  end
22
23
 
23
24
  def rows
24
- @rows ||= scope.klass.find_by_sql(rows_sql)
25
+ @rows ||= begin
26
+ total = tagged_calls
27
+ scope.klass.find_by_sql(rows_sql).map do |row|
28
+ calls = row.calls.to_i
29
+ Row.new(
30
+ value: row.value,
31
+ calls: calls,
32
+ total_cost: row.total_cost,
33
+ average_cost_per_call: row.average_cost_per_call,
34
+ share_percent: percentage(calls, total)
35
+ )
36
+ end
37
+ end
25
38
  end
26
39
 
27
40
  def total_calls
@@ -46,13 +59,14 @@ module LlmCostTracker
46
59
 
47
60
  def rows_sql
48
61
  <<~SQL.squish
49
- SELECT #{tag_expression} AS value,
62
+ SELECT #{tag_value_column} AS value,
50
63
  COUNT(*) AS calls,
51
64
  COALESCE(SUM(sub.total_cost), 0) AS total_cost,
52
65
  COALESCE(SUM(sub.total_cost), 0) / NULLIF(COUNT(*), 0) AS average_cost_per_call
53
66
  FROM (#{scope.to_sql}) AS sub
67
+ INNER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
54
68
  WHERE #{tag_present_predicate}
55
- GROUP BY #{tag_expression}
69
+ GROUP BY #{tag_value_column}
56
70
  ORDER BY total_cost DESC, calls DESC, value ASC
57
71
  LIMIT #{limit}
58
72
  SQL
@@ -61,18 +75,37 @@ module LlmCostTracker
61
75
  def summary_sql
62
76
  <<~SQL.squish
63
77
  SELECT COUNT(*) AS total_calls,
64
- COALESCE(SUM(CASE WHEN #{tag_present_predicate} THEN 1 ELSE 0 END), 0) AS tagged_calls,
65
- COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_expression} END) AS distinct_values
78
+ COUNT(t.#{quote_column('value')}) AS tagged_calls,
79
+ COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_value_column} END) AS distinct_values
66
80
  FROM (#{scope.to_sql}) AS sub
81
+ LEFT OUTER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
67
82
  SQL
68
83
  end
69
84
 
70
85
  def tag_present_predicate
71
- "#{tag_expression} IS NOT NULL AND #{tag_expression} != ''"
86
+ "#{tag_value_column} IS NOT NULL AND #{tag_value_column} != ''"
87
+ end
88
+
89
+ def tag_value_column
90
+ "t.#{quote_column('value')}"
91
+ end
92
+
93
+ def call_tag_table
94
+ LlmCostTracker::CallTag.quoted_table_name
72
95
  end
73
96
 
74
- def tag_expression
75
- @tag_expression ||= LlmCostTracker::Ledger::Call.tag_value_expression(key, table_name: "sub")
97
+ def quote_column(name)
98
+ scope.connection.quote_column_name(name)
99
+ end
100
+
101
+ def quoted_key
102
+ scope.connection.quote(key)
103
+ end
104
+
105
+ def percentage(numerator, denominator)
106
+ return 0.0 unless denominator.positive?
107
+
108
+ (numerator / denominator.to_f) * 100.0
76
109
  end
77
110
  end
78
111
  end
@@ -6,14 +6,14 @@ module LlmCostTracker
6
6
  DEFAULT_LIMIT = 100
7
7
 
8
8
  class << self
9
- def call(scope: LlmCostTracker::Ledger::Call.all, limit: DEFAULT_LIMIT)
9
+ def call(scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
10
10
  new(scope: scope, limit: limit).rows
11
11
  end
12
12
  end
13
13
 
14
14
  def initialize(scope:, limit:)
15
15
  @scope = scope
16
- @connection = LlmCostTracker::Ledger::Call.connection
16
+ @connection = LlmCostTracker::Call.connection
17
17
  limit = limit.to_i
18
18
  @limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
19
19
  end
@@ -29,50 +29,27 @@ module LlmCostTracker
29
29
 
30
30
  attr_reader :scope, :connection, :limit
31
31
 
32
- def subquery
33
- scope.to_sql
34
- end
35
-
36
32
  def build_sql
37
- return postgresql_sql if Ledger::Schema::Adapter.postgresql?(connection)
38
- return mysql_sql if Ledger::Schema::Adapter.mysql?(connection)
39
-
40
- Ledger::Schema::Adapter.ensure_supported!(connection)
41
- end
33
+ tags_table = LlmCostTracker::CallTag.quoted_table_name
42
34
 
43
- def mysql_sql
44
35
  <<~SQL.squish
45
- SELECT jt.key AS key,
36
+ SELECT t.#{key_column} AS #{key_column},
46
37
  COUNT(*) AS calls_count,
47
- COUNT(DISTINCT JSON_UNQUOTE(JSON_EXTRACT(sub.tags, CONCAT('$.', JSON_QUOTE(jt.key))))) AS distinct_values
48
- FROM (#{subquery}) AS sub
49
- JOIN JSON_TABLE(
50
- COALESCE(JSON_KEYS(sub.tags), JSON_ARRAY()),
51
- '$[*]' COLUMNS(
52
- key VARCHAR(255) PATH '$'
53
- )
54
- ) AS jt
55
- WHERE sub.tags IS NOT NULL
56
- AND sub.tags != ''
57
- GROUP BY jt.key
38
+ COUNT(DISTINCT t.#{value_column}) AS distinct_values
39
+ FROM (#{scope.to_sql}) AS sub
40
+ INNER JOIN #{tags_table} t ON t.llm_cost_tracker_call_id = sub.id
41
+ GROUP BY t.#{key_column}
58
42
  ORDER BY calls_count DESC
59
43
  LIMIT #{limit}
60
44
  SQL
61
45
  end
62
46
 
63
- def postgresql_sql
64
- <<~SQL.squish
65
- SELECT key,
66
- COUNT(*) AS calls_count,
67
- COUNT(DISTINCT (sub.tags::jsonb)->>key) AS distinct_values
68
- FROM (#{subquery}) AS sub,
69
- jsonb_object_keys(sub.tags::jsonb) AS key
70
- WHERE sub.tags IS NOT NULL
71
- AND sub.tags::jsonb <> '{}'::jsonb
72
- GROUP BY key
73
- ORDER BY calls_count DESC
74
- LIMIT #{limit}
75
- SQL
47
+ def key_column
48
+ connection.quote_column_name("key")
49
+ end
50
+
51
+ def value_column
52
+ connection.quote_column_name("value")
76
53
  end
77
54
  end
78
55
  end
@@ -8,7 +8,7 @@ module LlmCostTracker
8
8
  DEFAULT_DAYS = 30
9
9
 
10
10
  class << self
11
- def call(scope: LlmCostTracker::Ledger::Call.all, from: nil, to: Date.current)
11
+ def call(scope: LlmCostTracker::Call.all, from: nil, to: Date.current)
12
12
  new(scope: scope, from: from, to: to).points
13
13
  end
14
14
  end