llm_cost_tracker 0.14.1 → 0.15.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -37,19 +37,12 @@ module LlmCostTracker
37
37
  end
38
38
 
39
39
  def report(checks = call, color: $stdout.tty?)
40
- name_width = checks.map { |c| c.name.length }.max.to_i
41
-
42
- lines = [bold("LLM Cost Tracker doctor", color), ""]
43
- each_section(checks) do |section, members|
44
- lines << bold(section, color)
45
- members.each do |check|
46
- status = paint_status("[#{STATUS_GLYPHS.fetch(check.status, check.status)}]", check.status, color)
47
- lines << " #{status} #{"#{check.name}:".ljust(name_width + 1)} #{check.message}"
48
- end
49
- lines << ""
40
+ name_width = checks.map { |check| check.name.length }.max.to_i
41
+ sections = checks.group_by { |check| SECTION_FOR_CHECK.fetch(check.name, "Setup") }.slice(*SECTIONS)
42
+ blocks = sections.map do |section, members|
43
+ [bold(section, color), *members.map { |check| check_line(check, name_width, color) }].join("\n")
50
44
  end
51
- lines.pop if lines.last == ""
52
- lines.join("\n")
45
+ [bold("LLM Cost Tracker doctor", color), *blocks].join("\n\n")
53
46
  end
54
47
 
55
48
  def healthy?(checks = call)
@@ -58,13 +51,9 @@ module LlmCostTracker
58
51
 
59
52
  private
60
53
 
61
- def each_section(checks)
62
- SECTIONS.each do |section|
63
- members = checks.select { |c| (SECTION_FOR_CHECK[c.name] || "Setup") == section }
64
- next if members.empty?
65
-
66
- yield section, members
67
- end
54
+ def check_line(check, name_width, color)
55
+ status = paint_status("[#{STATUS_GLYPHS.fetch(check.status, check.status)}]", check.status, color)
56
+ " #{status} #{"#{check.name}:".ljust(name_width + 1)} #{check.message}"
68
57
  end
69
58
 
70
59
  def paint_status(text, status, color)
@@ -89,6 +78,7 @@ module LlmCostTracker
89
78
  table_check,
90
79
  column_check,
91
80
  *dependent_core_schema_checks,
81
+ per_tag_budget_check,
92
82
  call_rollups_check,
93
83
  IngestionCheck.new.call,
94
84
  PriceCheck.new.call,
@@ -106,6 +96,19 @@ module LlmCostTracker
106
96
  end
107
97
  end
108
98
 
99
+ def per_tag_budget_check
100
+ return if LlmCostTracker.configuration.budgets.per_tag.empty?
101
+ return unless Probe.table_exists?("llm_cost_tracker_call_tags")
102
+ return if Budget::PerTag.columns?
103
+
104
+ Check.new(
105
+ :error,
106
+ "per-tag budgets",
107
+ "budgets.per_tag is set but llm_cost_tracker_call_tags has no cost columns, so no per-tag budget is " \
108
+ "enforced; run bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets and migrate"
109
+ )
110
+ end
111
+
109
112
  def configuration_check
110
113
  config = LlmCostTracker.configuration
111
114
  Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
@@ -167,7 +170,7 @@ module LlmCostTracker
167
170
  return live_rollups_check unless LlmCostTracker.configuration.budgets.totals_source == :cache
168
171
 
169
172
  errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
170
- return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
173
+ return rollups_drift_check if errors.empty?
171
174
 
172
175
  Check.new(
173
176
  :error,
@@ -176,13 +179,30 @@ module LlmCostTracker
176
179
  )
177
180
  end
178
181
 
182
+ def rollups_drift_check
183
+ month_start = LlmCostTracker::Ledger::Period.range_start(:month, Time.now)
184
+ today = LlmCostTracker::Ledger::Period.range_start(:day, Time.now)
185
+ rolled = LlmCostTracker::CallRollup.where(period: "day", period_start: month_start.to_date...today.to_date)
186
+ .sum(:total_cost)
187
+ recorded = LlmCostTracker::Call.where(tracked_at: month_start...today).sum(:total_cost)
188
+ return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if rolled == recorded
189
+
190
+ Check.new(
191
+ :warn,
192
+ "call rollups",
193
+ "this month's day rollups before today total #{rolled.to_d.to_s('F')} but the calls total " \
194
+ "#{recorded.to_d.to_s('F')}, and monthly budgets read the rollups; " \
195
+ "run bin/rails llm_cost_tracker:rebuild_rollups"
196
+ )
197
+ end
198
+
179
199
  def live_rollups_check
180
200
  if Probe.table_exists?("llm_cost_tracker_call_rollups")
181
201
  Check.new(
182
202
  :warn,
183
203
  "call rollups",
184
204
  "budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
185
- "Set config.budgets.totals_source = :cache to keep budget reads on the rollups fast path or drop the table."
205
+ "Set config.budgets.totals_source = :cache to use it or drop the table."
186
206
  )
187
207
  else
188
208
  Check.new(
@@ -207,7 +227,7 @@ module LlmCostTracker
207
227
  end
208
228
 
209
229
  def active_record_available?
210
- LlmCostTracker::Call.connection
230
+ LlmCostTracker::Call.lease_connection
211
231
  true
212
232
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
213
233
  false
@@ -3,12 +3,19 @@
3
3
  require "rails"
4
4
  require_relative "../llm_cost_tracker"
5
5
  require_relative "assets"
6
- require "rack/files"
7
6
 
8
7
  module LlmCostTracker
9
8
  class Engine < ::Rails::Engine
10
9
  isolate_namespace LlmCostTracker
11
10
 
11
+ initializer "llm_cost_tracker.host_inflections" do |app|
12
+ Rails.autoloaders.each { |autoloader| autoloader.inflector.inflect("llm_cost_tracker" => "LlmCostTracker") }
13
+ app.reloader.to_prepare do
14
+ host_name = "llm_cost_tracker".camelize
15
+ Object.const_set(host_name, LlmCostTracker) unless Object.const_defined?(host_name)
16
+ end
17
+ end
18
+
12
19
  initializer "llm_cost_tracker.deprecator" do |app|
13
20
  app.deprecators[:llm_cost_tracker] = LlmCostTracker.deprecator
14
21
  end
@@ -27,10 +27,8 @@ module LlmCostTracker
27
27
  @stage = stage
28
28
  @scope = scope
29
29
 
30
- super(
31
- "LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: " \
32
- "$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
33
- )
30
+ amounts = budget_type == :calls ? "#{total} / #{budget}" : format("$%<total>.6f / $%<budget>.6f", total:, budget:)
31
+ super("LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: #{amounts}")
34
32
  end
35
33
 
36
34
  private
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/digest/uuid"
4
+
3
5
  require_relative "pricing/mode"
4
6
 
5
7
  module LlmCostTracker
@@ -22,43 +24,46 @@ module LlmCostTracker
22
24
  :cost_status,
23
25
  :pricing_snapshot,
24
26
  :line_items
25
- ) do
26
- def self.build(**attributes)
27
- token_usage = attributes.fetch(:token_usage)
28
- line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items])
27
+ )
28
+
29
+ class Event
30
+ UNKNOWN_MODEL = "unknown"
31
+ PROVIDER_IDS = %i[provider_response_id provider_project_id provider_api_key_id provider_workspace_id].freeze
32
+ PASSTHROUGH_FIELDS = %i[event_id pricing_mode cost tags latency_ms tracked_at cost_status pricing_snapshot].freeze
33
+ private_constant :PROVIDER_IDS, :PASSTHROUGH_FIELDS
29
34
 
35
+ def self.build(**attributes)
30
36
  new(
31
- event_id: attributes[:event_id],
37
+ token_usage: attributes.fetch(:token_usage),
38
+ line_items: attributes[:line_items] || resolve_line_items(attributes[:service_line_items]),
32
39
  provider: attributes.fetch(:provider).to_s,
33
- model: attributes.fetch(:model).to_s.strip.presence || Event::UNKNOWN_MODEL,
34
- token_usage: token_usage,
35
- pricing_mode: attributes[:pricing_mode],
36
- cost: attributes[:cost],
37
- tags: attributes[:tags],
38
- latency_ms: attributes[:latency_ms],
40
+ model: identifier(attributes.fetch(:model)) || UNKNOWN_MODEL,
39
41
  stream: attributes[:stream] || false,
40
42
  usage_source: attributes[:usage_source]&.to_s,
41
- provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
42
- provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
43
- provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
44
- provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
45
- tracked_at: attributes[:tracked_at],
46
- cost_status: attributes[:cost_status],
47
- pricing_snapshot: attributes[:pricing_snapshot],
48
- line_items: line_items
43
+ **PASSTHROUGH_FIELDS.to_h { |field| [field, attributes[field]] },
44
+ **PROVIDER_IDS.to_h { |field| [field, identifier(attributes[field])] }
49
45
  )
50
46
  end
51
47
 
52
- def batch?
53
- Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
54
- end
55
-
56
48
  def self.resolve_line_items(service_items)
57
49
  Array(service_items).map do |item|
58
50
  item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
59
51
  end
60
52
  end
61
53
 
54
+ def self.identifier(value) = value.to_s.strip.presence
55
+ private_class_method :identifier
56
+
57
+ def batch?
58
+ Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
59
+ end
60
+
61
+ def keyed_by_response_id
62
+ return self unless provider_response_id
63
+
64
+ with(event_id: Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "#{provider}/#{provider_response_id}"))
65
+ end
66
+
62
67
  def total_cost
63
68
  cost&.total
64
69
  end
@@ -72,8 +77,4 @@ module LlmCostTracker
72
77
  )
73
78
  end
74
79
  end
75
-
76
- class Event
77
- UNKNOWN_MODEL = "unknown"
78
- end
79
80
  end
@@ -32,12 +32,6 @@ module LlmCostTracker
32
32
  inline. The doctor check warns about unused async ingestion tables.
33
33
  MSG
34
34
  end
35
-
36
- private
37
-
38
- def migration_version
39
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
40
- end
41
35
  end
42
36
  end
43
37
  end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
13
+ desc "Creates the optional llm_cost_tracker_call_rollups table of running budget totals. " \
14
14
  "Required when config.budgets.totals_source = :cache."
15
15
 
16
16
  def create_migration_file
@@ -28,16 +28,13 @@ module LlmCostTracker
28
28
  config.budgets.totals_source = :cache
29
29
  end
30
30
 
31
- Without it Tracker keeps reading budget totals as live SUM aggregates over
31
+ Once that is deployed, run bin/rails llm_cost_tracker:rebuild_rollups. Until then
32
+ calls recorded before the switch are missing from monthly budget totals.
33
+
34
+ Without the setting Tracker keeps reading budget totals as live SUM aggregates over
32
35
  llm_cost_tracker_calls. The doctor check warns about an unused rollups table.
33
36
  MSG
34
37
  end
35
-
36
- private
37
-
38
- def migration_version
39
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
40
- end
41
38
  end
42
39
  end
43
40
  end
@@ -53,12 +53,6 @@ module LlmCostTracker
53
53
  unauthenticated would expose spend, tags, and provider IDs to anyone.
54
54
  MSG
55
55
  end
56
-
57
- private
58
-
59
- def migration_version
60
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
61
- end
62
56
  end
63
57
  end
64
58
  end
@@ -1,4 +1,4 @@
1
- class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
1
+ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
4
4
  t.string :event_id, null: false
@@ -1,4 +1,4 @@
1
- class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration<%= migration_version %>
1
+ class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :llm_cost_tracker_call_rollups do |t|
4
4
  t.string :period, null: false
@@ -1,7 +1,7 @@
1
1
  require "llm_cost_tracker/charges/cost_status"
2
2
  require "llm_cost_tracker/ledger/schema/adapter"
3
3
 
4
- class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
4
+ class CreateLlmCostTrackerCalls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
5
5
  def change
6
6
  create_table :llm_cost_tracker_calls do |t|
7
7
  t.string :event_id, null: false
@@ -84,8 +84,10 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
84
84
  add_index :llm_cost_tracker_calls, %i[model tracked_at]
85
85
  add_index :llm_cost_tracker_calls, :cost_status
86
86
  add_index :llm_cost_tracker_calls, :provider_response_id
87
- add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
88
- where: "total_cost IS NULL"
87
+ if postgresql?
88
+ add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
89
+ where: "total_cost IS NULL"
90
+ end
89
91
  add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
90
92
  add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
91
93
  if postgresql?
@@ -50,8 +50,9 @@ LlmCostTracker.configure do |config|
50
50
 
51
51
  # One budget applied to every distinct value of a tag: each tenant gets its own
52
52
  # 1000 a month, not 1000 shared between them. Declare as many tags as you need; a rule
53
- # can set its own behavior and callback instead of following the global ones. Needs the
54
- # cost columns from `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`.
53
+ # can set its own behavior and callback instead of following the global ones. Installs created
54
+ # before v0.14 add the cost columns with `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`
55
+ # and `bin/rails db:migrate`, then run `bin/rails llm_cost_tracker:backfill_tag_costs` so earlier calls count.
55
56
  # Budget high-cardinality tags only — a tag with a handful of values covers most of the
56
57
  # ledger, so its check cannot use an index and every call pays for a near-full scan.
57
58
  # config.budgets.per_tag = {
@@ -59,13 +60,14 @@ LlmCostTracker.configure do |config|
59
60
  # user_id: { daily: 25.00, behavior: :notify }
60
61
  # }
61
62
  # config.budgets.on_exceeded = ->(data) {
62
- # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
63
+ # unit = data[:budget_type] == :calls ? "" : "$"
64
+ # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: #{unit}#{data[:total]} / #{unit}#{data[:budget]}")
63
65
  # }
64
66
 
65
67
  # Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
66
- # every check; :cache also keeps running totals in llm_cost_tracker_call_rollups and
67
- # takes the greater of the two, so it guards against drift but does not make checks
68
- # faster. Generate the table first with `bin/rails generate llm_cost_tracker:call_rollups`.
68
+ # every check; :cache reads earlier days from llm_cost_tracker_call_rollups and sums
69
+ # only today's calls. Run `bin/rails generate llm_cost_tracker:call_rollups`, deploy
70
+ # this setting, then run `bin/rails llm_cost_tracker:rebuild_rollups`.
69
71
  # config.budgets.totals_source = :cache
70
72
 
71
73
  # Ingestion path — :inline (default) writes events synchronously from the request
@@ -74,13 +76,15 @@ LlmCostTracker.configure do |config|
74
76
  # inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
75
77
  # Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
76
78
  # so they don't compete with request threads for the default pool when a tracked call
77
- # happens inside an open caller transaction. Bump ingestion.pool_size if your Puma
78
- # worker count outgrows that.
79
+ # happens inside an open caller transaction. The pool is per process: bump ingestion.pool_size
80
+ # if concurrent tracked calls in one process (e.g. Puma threads) outgrow that.
79
81
  # config.ingestion.mode = :async
80
82
  # config.ingestion.pool_size = 5
81
83
 
82
- # Register OpenAI-compatible gateway hosts so their calls are attributed to a
83
- # provider name instead of the generic openai_compatible bucket.
84
+ # Register OpenAI-compatible gateway hosts beyond the built-in OpenRouter, DeepSeek, Groq,
85
+ # xAI, Mistral and Perplexity; the Faraday middleware captures only listed hosts. Its calls,
86
+ # and openai gem calls whose base_url is a listed host, are recorded under the provider name
87
+ # given here.
84
88
  # config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
85
89
 
86
90
  # Streaming calls only report token usage when the request asks for it. Leave this
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  TABLE = :llm_cost_tracker_call_rollups
@@ -27,6 +27,7 @@ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migr
27
27
 
28
28
  def add_unique_index(columns)
29
29
  if postgresql?
30
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, columns))
30
31
  add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
31
32
  else
32
33
  add_index TABLE, columns, unique: true, if_not_exists: true
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  TABLE = :llm_cost_tracker_call_tags
@@ -8,6 +8,7 @@ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= mi
8
8
 
9
9
  def up
10
10
  if postgresql?
11
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, INDEX_COLUMNS))
11
12
  add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
12
13
  elsif mysql?
13
14
  add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
@@ -1,4 +1,4 @@
1
- class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration<%= migration_version %>
1
+ class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  TABLE = :llm_cost_tracker_calls
3
3
  COLUMNS = %i[image_input_tokens image_output_tokens].freeze
4
4
 
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  CALLS = :llm_cost_tracker_calls
@@ -9,7 +9,8 @@ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%= migration_versio
9
9
  UNPRICED_INDEX = :index_llm_cost_tracker_calls_on_unpriced
10
10
 
11
11
  def up
12
- add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options
12
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, UNPRICED_INDEX)
13
+ add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options if postgresql?
13
14
  return unless table_exists?(INBOX)
14
15
 
15
16
  remove_index INBOX, column: UNUSED_INBOX_INDEX, **remove_index_options
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  TABLE = :llm_cost_tracker_call_tags
@@ -10,6 +10,7 @@ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%= migration_
10
10
  def up
11
11
  add_column TABLE, :total_cost, :decimal, precision: 20, scale: 8 unless column_exists?(TABLE, :total_cost)
12
12
  add_column TABLE, :tracked_at, :datetime unless column_exists?(TABLE, :tracked_at)
13
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, INDEX))
13
14
  add_index TABLE, INDEX, **add_index_options
14
15
  remove_index TABLE, column: REPLACED_INDEX, **remove_index_options
15
16
  end
@@ -18,12 +18,6 @@ module LlmCostTracker
18
18
  "db/migrate/upgrade_llm_cost_tracker_call_rollups_provider.rb"
19
19
  )
20
20
  end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -19,12 +19,6 @@ module LlmCostTracker
19
19
  "db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
20
20
  )
21
21
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
22
  end
29
23
  end
30
24
  end
@@ -18,12 +18,6 @@ module LlmCostTracker
18
18
  "db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
19
19
  )
20
20
  end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -10,8 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Adds a partial index over unpriced calls and drops the unused ingestion inbox lock index" \
14
- "scanning the whole ledger."
13
+ desc "Adds the unpriced-calls index (partial on PostgreSQL, so backfill_unknown_pricing stops scanning " \
14
+ "the whole ledger; a plain id index on MySQL, which has no partial indexes) and drops the unused " \
15
+ "ingestion inbox lock index."
15
16
 
16
17
  def create_migration_file
17
18
  migration_template(
@@ -19,12 +20,6 @@ module LlmCostTracker
19
20
  "db/migrate/upgrade_llm_cost_tracker_indexes.rb"
20
21
  )
21
22
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -10,8 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Carries each call's cost and time onto its tag rows so per-tag budgets read " \
14
- "llm_cost_tracker_call_tags without a join. Required when config.budgets.per_tag is set."
13
+ desc "Adds cost and time columns to llm_cost_tracker_call_tags so per-tag budgets read it " \
14
+ "without a join; run llm_cost_tracker:backfill_tag_costs afterwards so earlier calls count. " \
15
+ "Required for config.budgets.per_tag on installs created before v0.14."
15
16
 
16
17
  def create_migration_file
17
18
  migration_template(
@@ -19,12 +20,6 @@ module LlmCostTracker
19
20
  "db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
20
21
  )
21
22
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -12,7 +12,7 @@ module LlmCostTracker
12
12
  TRANSIENT_PERSIST_ERRORS = [
13
13
  ActiveRecord::Deadlocked,
14
14
  ActiveRecord::LockWaitTimeout,
15
- ActiveRecord::StatementTimeout,
15
+ ActiveRecord::QueryAborted,
16
16
  ActiveRecord::ConnectionNotEstablished,
17
17
  LlmCostTracker::TransactionAbortedError
18
18
  ].freeze
@@ -29,20 +29,16 @@ module LlmCostTracker
29
29
  persist_batch(valid_rows, events) if events.any?
30
30
  rows.size
31
31
  rescue StandardError => e
32
- rows_to_mark = valid_rows&.any? ? valid_rows : rows
33
- if rows_to_mark&.any?
34
- transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
35
- mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
36
- end
32
+ release_after_failure(rows, valid_rows, e)
37
33
  raise
38
34
  end
39
35
 
40
36
  def pending?
41
- Ingestion::InboxEntry.pending.exists?
37
+ Ingestion::InboxEntry.uncached { Ingestion::InboxEntry.pending.exists? }
42
38
  end
43
39
 
44
40
  def claimable?
45
- claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
41
+ Ingestion::InboxEntry.uncached { claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists? }
46
42
  end
47
43
 
48
44
  def mark_failed_with_message(rows, message, decrement_attempts: false)
@@ -67,6 +63,10 @@ module LlmCostTracker
67
63
  nil
68
64
  end
69
65
 
66
+ private
67
+
68
+ attr_reader :identity
69
+
70
70
  def error_message_for(error)
71
71
  "#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
72
72
  end
@@ -83,10 +83,6 @@ module LlmCostTracker
83
83
  )
84
84
  end
85
85
 
86
- private
87
-
88
- attr_reader :identity
89
-
90
86
  def id_sample(rows)
91
87
  sample = rows.first(10).map(&:id).join(", ")
92
88
  rows.size > 10 ? "#{sample}..." : sample
@@ -121,6 +117,18 @@ module LlmCostTracker
121
117
  [valid_rows, events]
122
118
  end
123
119
 
120
+ def release_after_failure(rows, valid_rows, error)
121
+ if valid_rows&.any?
122
+ mark_failed_with_message(valid_rows, error_message_for(error), decrement_attempts: transient?(error))
123
+ elsif rows&.any?
124
+ mark_failed_with_message(rows, error_message_for(error))
125
+ end
126
+ end
127
+
128
+ def transient?(error)
129
+ TRANSIENT_PERSIST_ERRORS.any? { |klass| error.is_a?(klass) }
130
+ end
131
+
124
132
  def persist_batch(rows, events)
125
133
  landed = []
126
134
  failed = Hash.new { |hash, message| hash[message] = [] }
@@ -128,14 +136,20 @@ module LlmCostTracker
128
136
  rescue *TRANSIENT_PERSIST_ERRORS
129
137
  raise
130
138
  rescue StandardError
131
- rows.zip(events) do |row, event|
132
- landed.concat(persist([row], [event]))
133
- rescue *TRANSIENT_PERSIST_ERRORS
134
- raise
135
- rescue StandardError => e
136
- failed[error_message_for(e)] << row
137
- end
139
+ rows.zip(events) { |row, event| persist_alone(row, event, landed, failed) }
138
140
  ensure
141
+ settle(landed, failed)
142
+ end
143
+
144
+ def persist_alone(row, event, landed, failed)
145
+ landed.concat(persist([row], [event]))
146
+ rescue *TRANSIENT_PERSIST_ERRORS
147
+ raise
148
+ rescue StandardError => e
149
+ failed[error_message_for(e)] << row
150
+ end
151
+
152
+ def settle(landed, failed)
139
153
  failed.each do |message, failed_rows|
140
154
  LlmCostTracker::Logging.warn(
141
155
  "Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \