llm_cost_tracker 0.5.2 → 0.5.3

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +8 -3
  4. data/docs/architecture.md +28 -0
  5. data/docs/budgets.md +45 -0
  6. data/docs/configuration.md +65 -0
  7. data/docs/cookbook.md +185 -0
  8. data/docs/dashboard-overview.png +0 -0
  9. data/docs/dashboard.md +38 -0
  10. data/docs/extending.md +32 -0
  11. data/docs/operations.md +44 -0
  12. data/docs/pricing.md +94 -0
  13. data/docs/querying.md +36 -0
  14. data/docs/streaming.md +70 -0
  15. data/docs/technical/README.md +10 -0
  16. data/docs/technical/data-flow.md +67 -0
  17. data/docs/technical/extension-points.md +111 -0
  18. data/docs/technical/module-map.md +197 -0
  19. data/docs/technical/operational-notes.md +77 -0
  20. data/docs/upgrading.md +46 -0
  21. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  22. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  23. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  24. data/lib/llm_cost_tracker/configuration.rb +2 -1
  25. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  26. data/lib/llm_cost_tracker/doctor.rb +6 -1
  27. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  28. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  29. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  30. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  31. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  32. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  33. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  34. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  35. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  36. data/lib/llm_cost_tracker/pricing/lookup.rb +110 -0
  37. data/lib/llm_cost_tracker/pricing.rb +25 -108
  38. data/lib/llm_cost_tracker/retention.rb +3 -9
  39. data/lib/llm_cost_tracker/storage/active_record_backend.rb +115 -0
  40. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +42 -0
  41. data/lib/llm_cost_tracker/storage/active_record_store.rb +26 -0
  42. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  43. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  44. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  45. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  46. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +3 -0
  49. data/lib/tasks/llm_cost_tracker.rake +49 -0
  50. metadata +32 -2
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ EffectivePriceSet = Data.define(:input, :cache_read_input, :cache_write_input, :output) do
6
+ def to_h
7
+ {
8
+ input: input,
9
+ cache_read_input: cache_read_input,
10
+ cache_write_input: cache_write_input,
11
+ output: output
12
+ }
13
+ end
14
+
15
+ def complete?
16
+ missing_keys.empty?
17
+ end
18
+
19
+ def missing_keys
20
+ to_h.filter_map { |key, value| key if value.nil? }
21
+ end
22
+ end
23
+
24
+ module EffectivePrices
25
+ class << self
26
+ def call(usage:, prices:, pricing_mode:)
27
+ EffectivePriceSet.new(
28
+ input: price_for_usage(usage.input_tokens, prices, :input, pricing_mode),
29
+ cache_read_input: price_for_cache_usage(
30
+ usage.cache_read_input_tokens,
31
+ prices,
32
+ :cache_read_input,
33
+ pricing_mode
34
+ ),
35
+ cache_write_input: price_for_cache_usage(
36
+ usage.cache_write_input_tokens,
37
+ prices,
38
+ :cache_write_input,
39
+ pricing_mode
40
+ ),
41
+ output: price_for_usage(usage.output_tokens, prices, :output, pricing_mode)
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def price_for_cache_usage(tokens, prices, key, pricing_mode)
48
+ return 0.0 unless tokens.positive?
49
+
50
+ price_for(prices, key, pricing_mode) || price_for(prices, :input, pricing_mode)
51
+ end
52
+
53
+ def price_for_usage(tokens, prices, key, pricing_mode)
54
+ tokens.positive? ? price_for(prices, key, pricing_mode) : 0.0
55
+ end
56
+
57
+ def price_for(prices, key, pricing_mode)
58
+ mode = normalized_pricing_mode(pricing_mode)
59
+ return prices[key] unless mode
60
+
61
+ prices[:"#{mode}_#{key}"] || prices[key]
62
+ end
63
+
64
+ def normalized_pricing_mode(value)
65
+ return nil if value.nil?
66
+
67
+ mode = value.to_s.strip
68
+ return nil if mode.empty? || mode == "standard"
69
+
70
+ mode
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -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,110 @@
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
+
11
+ class << self
12
+ def call(provider:, model:)
13
+ provider_name = provider.to_s
14
+ model_name = model.to_s
15
+ provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
16
+ normalized_model = normalize_model_name(model_name)
17
+ current = current_price_tables
18
+
19
+ explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
20
+ normalized_model) ||
21
+ explain_table(current.fetch(:file_prices), :prices_file, provider_model, model_name, normalized_model) ||
22
+ explain_table(Pricing::PRICES, :bundled, provider_model, model_name, normalized_model)
23
+ end
24
+
25
+ private
26
+
27
+ def current_price_tables
28
+ file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
29
+ overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
30
+ cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
31
+
32
+ cached = @prices_cache
33
+ return cached[:value] if cached && cached[:key] == cache_key
34
+
35
+ MUTEX.synchronize do
36
+ cached = @prices_cache
37
+ return cached[:value] if cached && cached[:key] == cache_key
38
+
39
+ value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
40
+ @prices_cache = { key: cache_key, value: value }.freeze
41
+ value
42
+ end
43
+ end
44
+
45
+ def explain_table(table, source, provider_model, model_name, normalized_model)
46
+ return nil if table.empty?
47
+
48
+ direct_match(table, source, provider_model, :provider_model) ||
49
+ direct_match(table, source, model_name, :model) ||
50
+ direct_match(table, source, normalized_model, :normalized_model) ||
51
+ unique_providerless_lookup(normalized_model, table, source) ||
52
+ fuzzy_match(provider_model, normalized_model, table, source) ||
53
+ unique_providerless_fuzzy_match(normalized_model, table, source)
54
+ end
55
+
56
+ def normalize_model_name(model)
57
+ model.to_s.split("/").last
58
+ end
59
+
60
+ def unique_providerless_lookup(model, table, source)
61
+ matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
62
+ match(table, source, matches.first, :unique_providerless_model) if matches.one?
63
+ end
64
+
65
+ def fuzzy_match(model, normalized_model, table, source)
66
+ sorted_price_keys(table).each do |key|
67
+ return match(table, source, key, :dated_snapshot) if snapshot_variant?(model, key) ||
68
+ snapshot_variant?(normalized_model, key)
69
+ end
70
+
71
+ nil
72
+ end
73
+
74
+ def unique_providerless_fuzzy_match(model, table, source)
75
+ matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
76
+ match(table, source, matches.first, :unique_providerless_dated_snapshot) if matches.one?
77
+ end
78
+
79
+ def direct_match(table, source, key, matched_by)
80
+ match(table, source, key, matched_by) if table.key?(key)
81
+ end
82
+
83
+ def match(table, source, key, matched_by)
84
+ Match.new(source.to_s, key, table[key], matched_by.to_s)
85
+ end
86
+
87
+ def snapshot_variant?(model, key)
88
+ suffix = model.delete_prefix("#{key}-")
89
+ return false if suffix == model
90
+
91
+ suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
92
+ end
93
+
94
+ def sorted_price_keys(table)
95
+ cached = @sorted_price_keys_cache
96
+ return cached[:keys] if cached && cached[:table].equal?(table)
97
+
98
+ MUTEX.synchronize do
99
+ cached = @sorted_price_keys_cache
100
+ return cached[:keys] if cached && cached[:table].equal?(table)
101
+
102
+ keys = table.keys.sort_by { |key| -key.length }
103
+ @sorted_price_keys_cache = { table: table, keys: keys }.freeze
104
+ keys
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ 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
@@ -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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "registry"
6
+ require_relative "active_record_store"
7
+
8
+ module LlmCostTracker
9
+ module Storage
10
+ class ActiveRecordBackend
11
+ VERIFY_TAG = "llm_cost_tracker_verify"
12
+
13
+ class << self
14
+ def save(event)
15
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
16
+
17
+ ActiveRecordStore.save(event)
18
+ event
19
+ rescue LoadError => e
20
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
21
+ end
22
+
23
+ def verify
24
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
25
+
26
+ unless LlmCostTracker::LlmApiCall.table_exists?
27
+ return [
28
+ VerificationResult.new(
29
+ :error,
30
+ "active_record",
31
+ "llm_api_calls table is missing; run install generator and migrate"
32
+ )
33
+ ]
34
+ end
35
+
36
+ [active_record_capture_check]
37
+ rescue LoadError => e
38
+ [VerificationResult.new(:error, "active_record", "unavailable: #{e.message}")]
39
+ rescue StandardError => e
40
+ [VerificationResult.new(:error, "active_record", "#{e.class}: #{e.message}")]
41
+ end
42
+
43
+ def prune(cutoff:, batch_size:)
44
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
45
+
46
+ ActiveRecordStore.prune(cutoff: cutoff, batch_size: batch_size)
47
+ end
48
+
49
+ private
50
+
51
+ def active_record_capture_check
52
+ provider, model = sample_priced_identity
53
+ response_id = "lct_verify_#{SecureRandom.hex(8)}"
54
+ notifications = []
55
+ persisted = false
56
+ subscription = subscribe_to_verification(response_id, notifications)
57
+
58
+ LlmCostTracker::LlmApiCall.transaction do
59
+ LlmCostTracker.track(
60
+ provider: provider,
61
+ model: model,
62
+ input_tokens: 1,
63
+ output_tokens: 1,
64
+ provider_response_id: response_id,
65
+ feature: VERIFY_TAG
66
+ )
67
+ persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
68
+ raise ActiveRecord::Rollback
69
+ end
70
+
71
+ return active_record_capture_success if persisted && notifications.any?
72
+
73
+ VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
74
+ rescue LlmCostTracker::BudgetExceededError => e
75
+ VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
76
+ rescue LlmCostTracker::Error => e
77
+ VerificationResult.new(:error, "active_record capture", e.message)
78
+ rescue StandardError => e
79
+ VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
80
+ ensure
81
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
82
+ end
83
+
84
+ def subscribe_to_verification(response_id, notifications)
85
+ ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
86
+ notifications << payload if payload[:provider_response_id] == response_id
87
+ end
88
+ end
89
+
90
+ def active_record_capture_success
91
+ VerificationResult.new(
92
+ :ok,
93
+ "active_record capture",
94
+ "manual event emitted and persisted inside rollback"
95
+ )
96
+ end
97
+
98
+ def capture_failure_message(persisted, notifications)
99
+ missing = []
100
+ missing << "notification" if notifications.empty?
101
+ missing << "persisted row" unless persisted
102
+ "missing #{missing.join(' and ')} for synthetic manual event"
103
+ end
104
+
105
+ def sample_priced_identity
106
+ key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
107
+ model_id.include?("/") && prices[:input] && prices[:output]
108
+ end&.first
109
+ provider, model = key.to_s.split("/", 2)
110
+ [provider || "openai", model || "gpt-4o-mini"]
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+
3
5
  module LlmCostTracker
4
6
  module Storage
5
7
  class ActiveRecordRollups
@@ -26,6 +28,15 @@ module LlmCostTracker
26
28
  )
27
29
  end
28
30
 
31
+ def decrement!(call_rows)
32
+ return unless period_totals_enabled?
33
+
34
+ totals = period_decrement_totals(call_rows)
35
+ return if totals.empty?
36
+
37
+ apply_decrements(totals)
38
+ end
39
+
29
40
  def monthly_total(time: Time.now.utc)
30
41
  period_totals(%i[monthly], time: time).fetch(:monthly)
31
42
  end
@@ -57,6 +68,37 @@ module LlmCostTracker
57
68
  end
58
69
  end
59
70
 
71
+ def period_decrement_totals(call_rows)
72
+ call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
73
+ _id, tracked_at, total_cost = row
74
+ next unless total_cost
75
+
76
+ PERIODS.each_key do |period|
77
+ totals[[period, bucket_for(period, tracked_at)]] += decimal(total_cost)
78
+ end
79
+ end
80
+ end
81
+
82
+ def apply_decrements(totals)
83
+ model = period_total_model
84
+ now = Time.now.utc
85
+
86
+ totals.each do |(period, period_start), amount|
87
+ row = model.lock.find_by(period: PERIODS.fetch(period), period_start: period_start)
88
+ next unless row
89
+
90
+ row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
91
+ end
92
+ end
93
+
94
+ def decremented_total(current, amount)
95
+ [decimal(current) - amount, BigDecimal("0")].max
96
+ end
97
+
98
+ def decimal(value)
99
+ BigDecimal(value.to_s)
100
+ end
101
+
60
102
  def rollup_period_totals(periods, time)
61
103
  buckets = periods.to_h { |period| [period, bucket_for(period, time)] }
62
104
  index = buckets.to_h { |period, bucket| [[PERIODS.fetch(period), bucket], period] }
@@ -54,8 +54,34 @@ module LlmCostTracker
54
54
  ActiveRecordRollups.period_totals(periods, time: time)
55
55
  end
56
56
 
57
+ def prune(cutoff:, batch_size:)
58
+ deleted = 0
59
+ loop do
60
+ batch = prune_batch(cutoff, batch_size)
61
+ deleted += batch
62
+ break if batch < batch_size
63
+ end
64
+ deleted
65
+ end
66
+
57
67
  private
58
68
 
69
+ def prune_batch(cutoff, batch_size)
70
+ LlmCostTracker::LlmApiCall.transaction do
71
+ rows = LlmCostTracker::LlmApiCall
72
+ .where(tracked_at: ...cutoff)
73
+ .order(:id)
74
+ .limit(batch_size)
75
+ .lock
76
+ .pluck(:id, :tracked_at, :total_cost)
77
+ next 0 if rows.empty?
78
+
79
+ deleted = LlmCostTracker::LlmApiCall.where(id: rows.map(&:first)).delete_all
80
+ ActiveRecordRollups.decrement!(rows) if deleted.positive?
81
+ deleted
82
+ end
83
+ end
84
+
59
85
  def stringify_tags(tags)
60
86
  tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
61
87
  end