llm_cost_tracker 0.5.2 → 0.6.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +8 -3
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  5. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  6. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  7. data/docs/architecture.md +28 -0
  8. data/docs/budgets.md +45 -0
  9. data/docs/configuration.md +65 -0
  10. data/docs/cookbook.md +185 -0
  11. data/docs/dashboard-overview.png +0 -0
  12. data/docs/dashboard.md +38 -0
  13. data/docs/extending.md +32 -0
  14. data/docs/operations.md +44 -0
  15. data/docs/pricing.md +94 -0
  16. data/docs/querying.md +36 -0
  17. data/docs/streaming.md +70 -0
  18. data/docs/technical/README.md +10 -0
  19. data/docs/technical/data-flow.md +70 -0
  20. data/docs/technical/extension-points.md +111 -0
  21. data/docs/technical/module-map.md +197 -0
  22. data/docs/technical/operational-notes.md +97 -0
  23. data/docs/upgrading.md +47 -0
  24. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  25. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  26. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  27. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  28. data/lib/llm_cost_tracker/configuration.rb +2 -1
  29. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  30. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  31. data/lib/llm_cost_tracker/doctor.rb +8 -1
  32. data/lib/llm_cost_tracker/event.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  40. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  41. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  43. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  44. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  45. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  46. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  47. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  48. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  49. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  50. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  51. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  52. data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
  53. data/lib/llm_cost_tracker/pricing.rb +25 -108
  54. data/lib/llm_cost_tracker/railtie.rb +1 -0
  55. data/lib/llm_cost_tracker/retention.rb +3 -9
  56. data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
  57. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  58. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  59. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  60. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  61. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  62. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  63. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  64. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  65. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  66. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
  67. data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
  68. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  69. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  70. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  71. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  72. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  73. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  74. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  75. data/lib/llm_cost_tracker/tracker.rb +3 -0
  76. data/lib/llm_cost_tracker/version.rb +1 -1
  77. data/lib/llm_cost_tracker.rb +39 -1
  78. data/lib/tasks/llm_cost_tracker.rake +49 -0
  79. metadata +47 -2
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effective_prices"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ Explanation = Data.define(
8
+ :provider,
9
+ :model,
10
+ :pricing_mode,
11
+ :source,
12
+ :matched_key,
13
+ :matched_by,
14
+ :prices,
15
+ :effective_prices,
16
+ :missing_price_keys
17
+ ) do
18
+ def matched? = !prices.nil?
19
+
20
+ def complete? = matched? && missing_price_keys.empty?
21
+
22
+ def message
23
+ return "No price entry matched #{provider}/#{model}" unless matched?
24
+ return "Matched #{matched_key} from #{source} via #{matched_by}" if complete?
25
+
26
+ "Matched #{matched_key} from #{source} via #{matched_by}, but missing #{missing_price_keys.join(', ')}"
27
+ end
28
+ end
29
+
30
+ module Explainer
31
+ class << self
32
+ def call(provider:, model:, input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0,
33
+ cache_write_input_tokens: 0, pricing_mode: nil)
34
+ match = Lookup.call(provider: provider, model: model)
35
+ usage = match && UsageBreakdown.build(
36
+ input_tokens: input_tokens,
37
+ output_tokens: output_tokens,
38
+ cache_read_input_tokens: cache_read_input_tokens,
39
+ cache_write_input_tokens: cache_write_input_tokens
40
+ )
41
+
42
+ explanation(provider, model, pricing_mode, match, usage)
43
+ end
44
+
45
+ private
46
+
47
+ def explanation(provider, model, pricing_mode, match, usage)
48
+ prices = match&.prices
49
+ effective = if prices && usage
50
+ EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
51
+ end
52
+
53
+ Explanation.new(
54
+ provider.to_s,
55
+ model.to_s,
56
+ normalized_pricing_mode(pricing_mode),
57
+ match&.source,
58
+ match&.key,
59
+ match&.matched_by,
60
+ prices,
61
+ effective ? effective.to_h : {},
62
+ effective ? effective.missing_keys : []
63
+ )
64
+ end
65
+
66
+ def normalized_pricing_mode(value)
67
+ return nil if value.nil?
68
+
69
+ mode = value.to_s.strip
70
+ return nil if mode.empty? || mode == "standard"
71
+
72
+ mode
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ module Lookup
8
+ Match = Data.define(:source, :key, :prices, :matched_by)
9
+ MUTEX = Monitor.new
10
+ CACHE_MISS = Object.new.freeze
11
+ NO_MATCH = Object.new.freeze
12
+ MAX_LOOKUP_CACHE_ENTRIES = 512
13
+
14
+ class << self
15
+ def call(provider:, model:)
16
+ provider_name = provider.to_s
17
+ model_name = model.to_s
18
+ generation = LlmCostTracker.configuration_generation
19
+ cache_key = [generation, provider_name, model_name]
20
+ cached = cached_lookup(cache_key)
21
+ return cached unless cached.equal?(CACHE_MISS)
22
+
23
+ provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
24
+ normalized_model = normalize_model_name(model_name)
25
+ current = current_price_tables(generation)
26
+
27
+ match =
28
+ explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
29
+ normalized_model) ||
30
+ explain_table(current.fetch(:file_prices), :prices_file, provider_model, model_name, normalized_model) ||
31
+ explain_table(Pricing::PRICES, :bundled, provider_model, model_name, normalized_model)
32
+ cache_lookup(cache_key, match)
33
+ match
34
+ end
35
+
36
+ private
37
+
38
+ def current_price_tables(generation)
39
+ cached = @prices_cache
40
+ return cached[:value] if cached && cached[:generation] == generation
41
+
42
+ MUTEX.synchronize do
43
+ cached = @prices_cache
44
+ return cached[:value] if cached && cached[:generation] == generation
45
+
46
+ config = LlmCostTracker.configuration
47
+ file_prices = PriceRegistry.file_prices(config.prices_file)
48
+ overrides = PriceRegistry.normalize_price_table(config.pricing_overrides)
49
+ value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
50
+ @prices_cache = { generation: generation, value: value }.freeze
51
+ value
52
+ end
53
+ end
54
+
55
+ def cached_lookup(cache_key)
56
+ cached = @lookup_cache
57
+ return CACHE_MISS unless cached && cached[:generation] == cache_key.first
58
+ return CACHE_MISS unless cached[:values].key?(cache_key)
59
+
60
+ match = cached[:values].fetch(cache_key)
61
+ match.equal?(NO_MATCH) ? nil : match
62
+ end
63
+
64
+ def cache_lookup(cache_key, match)
65
+ MUTEX.synchronize do
66
+ cached = @lookup_cache
67
+ values = if cached && cached[:generation] == cache_key.first
68
+ cached[:values].dup
69
+ else
70
+ {}
71
+ end
72
+ values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
73
+ values[cache_key] = match || NO_MATCH
74
+ @lookup_cache = { generation: cache_key.first, values: values.freeze }.freeze
75
+ end
76
+ end
77
+
78
+ def explain_table(table, source, provider_model, model_name, normalized_model)
79
+ return nil if table.empty?
80
+
81
+ direct_match(table, source, provider_model, :provider_model) ||
82
+ direct_match(table, source, model_name, :model) ||
83
+ direct_match(table, source, normalized_model, :normalized_model) ||
84
+ unique_providerless_lookup(normalized_model, table, source) ||
85
+ fuzzy_match(provider_model, normalized_model, table, source) ||
86
+ unique_providerless_fuzzy_match(normalized_model, table, source)
87
+ end
88
+
89
+ def normalize_model_name(model)
90
+ model.to_s.split("/").last
91
+ end
92
+
93
+ def unique_providerless_lookup(model, table, source)
94
+ matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
95
+ match(table, source, matches.first, :unique_providerless_model) if matches.one?
96
+ end
97
+
98
+ def fuzzy_match(model, normalized_model, table, source)
99
+ sorted_price_keys(table).each do |key|
100
+ return match(table, source, key, :dated_snapshot) if snapshot_variant?(model, key) ||
101
+ snapshot_variant?(normalized_model, key)
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ def unique_providerless_fuzzy_match(model, table, source)
108
+ matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
109
+ match(table, source, matches.first, :unique_providerless_dated_snapshot) if matches.one?
110
+ end
111
+
112
+ def direct_match(table, source, key, matched_by)
113
+ match(table, source, key, matched_by) if table.key?(key)
114
+ end
115
+
116
+ def match(table, source, key, matched_by)
117
+ Match.new(source.to_s, key, table[key], matched_by.to_s)
118
+ end
119
+
120
+ def snapshot_variant?(model, key)
121
+ suffix = model.delete_prefix("#{key}-")
122
+ return false if suffix == model
123
+
124
+ suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
125
+ end
126
+
127
+ def sorted_price_keys(table)
128
+ cached = @sorted_price_keys_cache
129
+ return cached[:keys] if cached && cached[:table].equal?(table)
130
+
131
+ MUTEX.synchronize do
132
+ cached = @sorted_price_keys_cache
133
+ return cached[:keys] if cached && cached[:table].equal?(table)
134
+
135
+ keys = table.keys.sort_by { |key| -key.length }
136
+ @sorted_price_keys_cache = { table: table, keys: keys }.freeze
137
+ keys
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
3
+ require_relative "pricing/lookup"
4
+ require_relative "pricing/effective_prices"
5
+ require_relative "pricing/explainer"
4
6
 
5
7
  module LlmCostTracker
6
8
  module Pricing
7
9
  PRICES = PriceRegistry.builtin_prices
8
- MUTEX = Monitor.new
9
10
 
10
11
  class << self
11
12
  def cost_for(provider:, model:, input_tokens:, output_tokens:, cache_read_input_tokens: 0,
@@ -20,6 +21,7 @@ module LlmCostTracker
20
21
  cache_write_input_tokens: cache_write_input_tokens
21
22
  )
22
23
  costs = calculate_costs(usage, prices, pricing_mode: pricing_mode)
24
+ return nil unless costs
23
25
 
24
26
  Cost.new(
25
27
  input_cost: costs[:input].round(8),
@@ -32,127 +34,42 @@ module LlmCostTracker
32
34
  end
33
35
 
34
36
  def lookup(provider:, model:)
35
- provider_name = provider.to_s
36
- model_name = model.to_s
37
- provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
38
- normalized_model = normalize_model_name(model_name)
39
- current = current_price_tables
40
-
41
- lookup_in_table(current.fetch(:pricing_overrides), provider_model, model_name, normalized_model) ||
42
- lookup_in_table(current.fetch(:file_prices), provider_model, model_name, normalized_model) ||
43
- lookup_in_table(PRICES, provider_model, model_name, normalized_model)
37
+ Lookup.call(provider: provider, model: model)&.prices
44
38
  end
45
39
 
46
- private
47
-
48
- def current_price_tables
49
- file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
50
- overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
51
- cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
52
-
53
- cached = @prices_cache
54
- return cached[:value] if cached && cached[:key] == cache_key
55
-
56
- MUTEX.synchronize do
57
- cached = @prices_cache
58
- return cached[:value] if cached && cached[:key] == cache_key
59
-
60
- value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
61
- @prices_cache = { key: cache_key, value: value }.freeze
62
- value
63
- end
40
+ def explain(provider:, model:, input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0,
41
+ cache_write_input_tokens: 0, pricing_mode: nil)
42
+ Explainer.call(
43
+ provider: provider,
44
+ model: model,
45
+ input_tokens: input_tokens,
46
+ output_tokens: output_tokens,
47
+ cache_read_input_tokens: cache_read_input_tokens,
48
+ cache_write_input_tokens: cache_write_input_tokens,
49
+ pricing_mode: pricing_mode
50
+ )
64
51
  end
65
52
 
66
- def lookup_in_table(table, provider_model, model_name, normalized_model)
67
- return nil if table.empty?
68
-
69
- table[provider_model] ||
70
- table[model_name] ||
71
- table[normalized_model] ||
72
- unique_providerless_lookup(normalized_model, table) ||
73
- fuzzy_match(provider_model, normalized_model, table) ||
74
- unique_providerless_fuzzy_match(normalized_model, table)
75
- end
53
+ private
76
54
 
77
55
  def calculate_costs(usage, prices, pricing_mode:)
56
+ effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
57
+ return nil unless effective.complete?
58
+
78
59
  {
79
- input: token_cost(usage.input_tokens, price_for(prices, :input, pricing_mode)),
80
- cache_read_input: token_cost(
81
- usage.cache_read_input_tokens,
82
- price_for(prices, :cache_read_input, pricing_mode) || price_for(prices, :input, pricing_mode)
83
- ),
84
- cache_write_input: token_cost(
85
- usage.cache_write_input_tokens,
86
- price_for(prices, :cache_write_input, pricing_mode) || price_for(prices, :input, pricing_mode)
87
- ),
88
- output: token_cost(usage.output_tokens, price_for(prices, :output, pricing_mode))
60
+ input: token_cost(usage.input_tokens, effective.input),
61
+ cache_read_input: token_cost(usage.cache_read_input_tokens, effective.cache_read_input),
62
+ cache_write_input: token_cost(usage.cache_write_input_tokens, effective.cache_write_input),
63
+ output: token_cost(usage.output_tokens, effective.output)
89
64
  }
90
65
  end
91
66
 
92
- def price_for(prices, key, pricing_mode)
93
- mode = normalized_pricing_mode(pricing_mode)
94
- return prices[key] unless mode
95
-
96
- prices[:"#{mode}_#{key}"] || prices[key]
97
- end
98
-
99
- def normalized_pricing_mode(value)
100
- return nil if value.nil?
101
-
102
- mode = value.to_s.strip
103
- return nil if mode.empty? || mode == "standard"
104
-
105
- mode
106
- end
107
-
108
67
  def token_cost(tokens, per_million_price)
109
68
  return 0.0 if tokens.to_i.zero?
69
+ return nil if per_million_price.nil?
110
70
 
111
71
  (tokens.to_f / 1_000_000) * per_million_price
112
72
  end
113
-
114
- def normalize_model_name(model)
115
- model.to_s.split("/").last
116
- end
117
-
118
- def unique_providerless_lookup(model, table)
119
- matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
120
- table[matches.first] if matches.one?
121
- end
122
-
123
- def fuzzy_match(model, normalized_model, table)
124
- sorted_price_keys(table).each do |key|
125
- return table[key] if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
126
- end
127
-
128
- nil
129
- end
130
-
131
- def unique_providerless_fuzzy_match(model, table)
132
- matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
133
- table[matches.first] if matches.one?
134
- end
135
-
136
- def snapshot_variant?(model, key)
137
- suffix = model.delete_prefix("#{key}-")
138
- return false if suffix == model
139
-
140
- suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
141
- end
142
-
143
- def sorted_price_keys(table)
144
- cached = @sorted_price_keys_cache
145
- return cached[:keys] if cached && cached[:table].equal?(table)
146
-
147
- MUTEX.synchronize do
148
- cached = @sorted_price_keys_cache
149
- return cached[:keys] if cached && cached[:table].equal?(table)
150
-
151
- keys = table.keys.sort_by { |key| -key.length }
152
- @sorted_price_keys_cache = { table: table, keys: keys }.freeze
153
- keys
154
- end
155
- end
156
73
  end
157
74
  end
158
75
  end
@@ -3,6 +3,7 @@
3
3
  module LlmCostTracker
4
4
  class Railtie < Rails::Railtie
5
5
  generators do
6
+ require_relative "generators/llm_cost_tracker/add_ingestion_generator"
6
7
  require_relative "generators/llm_cost_tracker/add_period_totals_generator"
7
8
  require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
8
9
  require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
@@ -8,15 +8,9 @@ module LlmCostTracker
8
8
  def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
9
9
  batch_size = normalized_batch_size(batch_size)
10
10
  cutoff = resolve_cutoff(older_than, now)
11
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
12
-
13
- deleted = 0
14
- loop do
15
- batch = LlmCostTracker::LlmApiCall.where(tracked_at: ...cutoff).limit(batch_size).delete_all
16
- deleted += batch
17
- break if batch < batch_size
18
- end
19
- deleted
11
+ require_relative "storage/active_record_backend"
12
+
13
+ Storage::ActiveRecordBackend.prune(cutoff: cutoff, batch_size: batch_size)
20
14
  end
21
15
 
22
16
  private
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "registry"
6
+ require_relative "active_record_inbox"
7
+ require_relative "active_record_ingestor"
8
+ require_relative "active_record_store"
9
+
10
+ module LlmCostTracker
11
+ module Storage
12
+ class ActiveRecordBackend
13
+ VERIFY_TAG = "llm_cost_tracker_verify"
14
+
15
+ class << self
16
+ def save(event)
17
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
18
+
19
+ if ActiveRecordInbox.enabled?
20
+ ActiveRecordInbox.save(event)
21
+ else
22
+ ActiveRecordStore.save(event)
23
+ end
24
+ event
25
+ rescue LoadError => e
26
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
27
+ end
28
+
29
+ def verify
30
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
31
+
32
+ unless LlmCostTracker::LlmApiCall.table_exists?
33
+ return [
34
+ VerificationResult.new(
35
+ :error,
36
+ "active_record",
37
+ "llm_api_calls table is missing; run install generator and migrate"
38
+ )
39
+ ]
40
+ end
41
+
42
+ [active_record_capture_check]
43
+ rescue LoadError => e
44
+ [VerificationResult.new(:error, "active_record", "unavailable: #{e.message}")]
45
+ rescue StandardError => e
46
+ [VerificationResult.new(:error, "active_record", "#{e.class}: #{e.message}")]
47
+ end
48
+
49
+ def prune(cutoff:, batch_size:)
50
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
51
+
52
+ ActiveRecordStore.prune(cutoff: cutoff, batch_size: batch_size)
53
+ end
54
+
55
+ private
56
+
57
+ def active_record_capture_check
58
+ return active_record_inbox_capture_check if ActiveRecordInbox.enabled?
59
+
60
+ provider, model = sample_priced_identity
61
+ response_id = "lct_verify_#{SecureRandom.hex(8)}"
62
+ notifications = []
63
+ persisted = false
64
+ subscription = subscribe_to_verification(response_id, notifications)
65
+
66
+ LlmCostTracker::LlmApiCall.transaction do
67
+ LlmCostTracker.track(
68
+ provider: provider,
69
+ model: model,
70
+ input_tokens: 1,
71
+ output_tokens: 1,
72
+ provider_response_id: response_id,
73
+ feature: VERIFY_TAG
74
+ )
75
+ persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
76
+ raise ActiveRecord::Rollback
77
+ end
78
+
79
+ return active_record_capture_success if persisted && notifications.any?
80
+
81
+ VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
82
+ rescue LlmCostTracker::BudgetExceededError => e
83
+ VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
84
+ rescue LlmCostTracker::Error => e
85
+ VerificationResult.new(:error, "active_record capture", e.message)
86
+ rescue StandardError => e
87
+ VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
88
+ ensure
89
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
90
+ end
91
+
92
+ def active_record_inbox_capture_check
93
+ provider, model = sample_priced_identity
94
+ response_id = "lct_verify_#{SecureRandom.hex(8)}"
95
+ notifications = []
96
+ subscription = subscribe_to_verification(response_id, notifications)
97
+
98
+ event = LlmCostTracker.track(
99
+ provider: provider,
100
+ model: model,
101
+ input_tokens: 1,
102
+ output_tokens: 1,
103
+ provider_response_id: response_id,
104
+ feature: VERIFY_TAG
105
+ )
106
+ LlmCostTracker.flush!
107
+ persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
108
+
109
+ if persisted && notifications.any?
110
+ return active_record_capture_success("manual event emitted and persisted through durable inbox")
111
+ end
112
+
113
+ VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
114
+ rescue LlmCostTracker::BudgetExceededError => e
115
+ VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
116
+ rescue LlmCostTracker::Error => e
117
+ VerificationResult.new(:error, "active_record capture", e.message)
118
+ rescue StandardError => e
119
+ VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
120
+ ensure
121
+ cleanup_verification_call(response_id) if response_id
122
+ LlmCostTracker::InboxEvent.where(event_id: event.event_id).delete_all if event
123
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
124
+ end
125
+
126
+ def subscribe_to_verification(response_id, notifications)
127
+ ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
128
+ notifications << payload if payload[:provider_response_id] == response_id
129
+ end
130
+ end
131
+
132
+ def active_record_capture_success(message = "manual event emitted and persisted inside rollback")
133
+ VerificationResult.new(
134
+ :ok,
135
+ "active_record capture",
136
+ message
137
+ )
138
+ end
139
+
140
+ def capture_failure_message(persisted, notifications)
141
+ missing = []
142
+ missing << "notification" if notifications.empty?
143
+ missing << "persisted row" unless persisted
144
+ "missing #{missing.join(' and ')} for synthetic manual event"
145
+ end
146
+
147
+ def cleanup_verification_call(response_id)
148
+ relation = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id)
149
+ rows = relation.pluck(:id, :tracked_at, :total_cost)
150
+ return if rows.empty?
151
+
152
+ relation.delete_all
153
+ ActiveRecordRollups.decrement!(rows)
154
+ end
155
+
156
+ def sample_priced_identity
157
+ key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
158
+ model_id.include?("/") && prices[:input] && prices[:output]
159
+ end&.first
160
+ provider, model = key.to_s.split("/", 2)
161
+ [provider || "openai", model || "gpt-4o-mini"]
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Storage
5
+ module ActiveRecordConnectionCleanup
6
+ def self.release!
7
+ ActiveRecord::Base.connection_handler.clear_active_connections!
8
+ rescue StandardError
9
+ nil
10
+ end
11
+ end
12
+ end
13
+ end