llm_cost_tracker 0.5.0 → 0.5.2

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +116 -467
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
  5. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
  6. data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
  8. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
  9. data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
  10. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
  11. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
  12. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
  13. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
  14. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  15. data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
  16. data/lib/llm_cost_tracker/configuration.rb +22 -16
  17. data/lib/llm_cost_tracker/doctor.rb +1 -1
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
  20. data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
  21. data/lib/llm_cost_tracker/integrations/base.rb +77 -6
  22. data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
  23. data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
  24. data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
  25. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
  26. data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
  27. data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
  28. data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
  29. data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
  30. data/lib/llm_cost_tracker/price_freshness.rb +3 -3
  31. data/lib/llm_cost_tracker/price_registry.rb +3 -0
  32. data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
  33. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  34. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
  35. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  36. data/lib/llm_cost_tracker/price_sync.rb +103 -111
  37. data/lib/llm_cost_tracker/prices.json +225 -229
  38. data/lib/llm_cost_tracker/pricing.rb +27 -15
  39. data/lib/llm_cost_tracker/report.rb +8 -1
  40. data/lib/llm_cost_tracker/report_data.rb +25 -9
  41. data/lib/llm_cost_tracker/retention.rb +30 -7
  42. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  43. data/lib/llm_cost_tracker/stream_capture.rb +7 -0
  44. data/lib/llm_cost_tracker/stream_collector.rb +25 -1
  45. data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
  46. data/lib/llm_cost_tracker/tracker.rb +7 -59
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +1 -0
  49. data/lib/tasks/llm_cost_tracker.rake +24 -78
  50. metadata +26 -15
  51. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  52. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  53. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  54. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
  55. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  56. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  57. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  58. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  59. data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
@@ -32,27 +32,20 @@ module LlmCostTracker
32
32
  end
33
33
 
34
34
  def lookup(provider:, model:)
35
- table = prices
36
35
  provider_name = provider.to_s
37
36
  model_name = model.to_s
38
37
  provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
39
38
  normalized_model = normalize_model_name(model_name)
39
+ current = current_price_tables
40
40
 
41
- table[provider_model] ||
42
- table[model_name] ||
43
- table[normalized_model] ||
44
- fuzzy_match(provider_model, normalized_model, table)
45
- end
46
-
47
- def models
48
- prices.keys
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)
49
44
  end
50
45
 
51
- def metadata
52
- PriceRegistry.metadata
53
- end
46
+ private
54
47
 
55
- def prices
48
+ def current_price_tables
56
49
  file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
57
50
  overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
58
51
  cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
@@ -64,13 +57,22 @@ module LlmCostTracker
64
57
  cached = @prices_cache
65
58
  return cached[:value] if cached && cached[:key] == cache_key
66
59
 
67
- value = PRICES.merge(file_prices).merge(overrides).freeze
60
+ value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
68
61
  @prices_cache = { key: cache_key, value: value }.freeze
69
62
  value
70
63
  end
71
64
  end
72
65
 
73
- private
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
74
76
 
75
77
  def calculate_costs(usage, prices, pricing_mode:)
76
78
  {
@@ -113,6 +115,11 @@ module LlmCostTracker
113
115
  model.to_s.split("/").last
114
116
  end
115
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
+
116
123
  def fuzzy_match(model, normalized_model, table)
117
124
  sorted_price_keys(table).each do |key|
118
125
  return table[key] if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
@@ -121,6 +128,11 @@ module LlmCostTracker
121
128
  nil
122
129
  end
123
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
+
124
136
  def snapshot_variant?(model, key)
125
137
  suffix = model.delete_prefix("#{key}-")
126
138
  return false if suffix == model
@@ -9,7 +9,14 @@ module LlmCostTracker
9
9
 
10
10
  class << self
11
11
  def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
12
- ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
12
+ report_data = ReportData.build(
13
+ days: days,
14
+ now: now,
15
+ tag_breakdowns: tag_breakdowns,
16
+ breakdown_limit: ReportFormatter::TOP_LIMIT
17
+ )
18
+
19
+ ReportFormatter.new(report_data).to_s
13
20
  rescue LoadError => e
14
21
  "Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
15
22
  rescue StandardError => e
@@ -23,10 +23,11 @@ module LlmCostTracker
23
23
  DEFAULT_DAYS = 30
24
24
  TOP_LIMIT = 5
25
25
 
26
- def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
26
+ def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
27
27
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
28
28
 
29
29
  days = normalized_days(days)
30
+ breakdown_limit = normalized_limit(breakdown_limit)
30
31
  from = now - days.days
31
32
  scope = LlmApiCall.where(tracked_at: from..now)
32
33
  tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
@@ -39,9 +40,9 @@ module LlmCostTracker
39
40
  requests_count: scope.count,
40
41
  average_latency_ms: average_latency_ms(scope),
41
42
  unknown_pricing_count: scope.where(total_cost: nil).count,
42
- cost_by_provider: cost_by(scope, :provider),
43
- cost_by_model: cost_by(scope, :model),
44
- cost_by_tags: cost_by_tags(scope, tag_breakdowns),
43
+ cost_by_provider: cost_by(scope, :provider, limit: breakdown_limit),
44
+ cost_by_model: cost_by(scope, :model, limit: breakdown_limit),
45
+ cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
45
46
  top_calls: top_calls(scope)
46
47
  )
47
48
  end
@@ -51,18 +52,33 @@ module LlmCostTracker
51
52
  days.positive? ? days : DEFAULT_DAYS
52
53
  end
53
54
 
55
+ def self.normalized_limit(limit)
56
+ return nil if limit.nil?
57
+
58
+ limit = limit.to_i
59
+ limit.positive? ? limit : nil
60
+ end
61
+
54
62
  def self.average_latency_ms(scope)
55
63
  return nil unless LlmApiCall.latency_column?
56
64
 
57
65
  scope.average(:latency_ms)&.to_f
58
66
  end
59
67
 
60
- def self.cost_by(scope, column)
61
- scope.group(column).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
68
+ def self.cost_by(scope, column, limit:)
69
+ relation = scope.group(column)
70
+ .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
71
+
72
+ relation = relation.limit(limit) if limit
73
+
74
+ relation
75
+ .sum(:total_cost)
76
+ .transform_values(&:to_f)
77
+ .sort_by { |_name, cost| -cost }
62
78
  end
63
79
 
64
- def self.cost_by_tags(scope, keys)
65
- keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
80
+ def self.cost_by_tags(scope, keys, limit:)
81
+ keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
66
82
  end
67
83
 
68
84
  def self.top_calls(scope)
@@ -73,6 +89,6 @@ module LlmCostTracker
73
89
  .map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
74
90
  end
75
91
 
76
- private_class_method :normalized_days, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
92
+ private_class_method :normalized_days, :normalized_limit, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
77
93
  end
78
94
  end
@@ -6,6 +6,7 @@ module LlmCostTracker
6
6
 
7
7
  class << self
8
8
  def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
9
+ batch_size = normalized_batch_size(batch_size)
9
10
  cutoff = resolve_cutoff(older_than, now)
10
11
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
11
12
 
@@ -20,14 +21,36 @@ module LlmCostTracker
20
21
 
21
22
  private
22
23
 
24
+ def normalized_batch_size(value)
25
+ value = value.to_i
26
+ raise ArgumentError, "batch_size must be positive: #{value.inspect}" unless value.positive?
27
+
28
+ value
29
+ end
30
+
23
31
  def resolve_cutoff(older_than, now)
24
- case older_than
25
- when Time, DateTime then older_than.utc
26
- when ActiveSupport::Duration then now - older_than
27
- when Integer then now - (older_than * 86_400)
28
- else
29
- raise ArgumentError, "older_than must be a Duration, Time, or Integer days: #{older_than.inspect}"
30
- end
32
+ cutoff = case older_than
33
+ when Time, DateTime then older_than.utc
34
+ when ActiveSupport::Duration then duration_cutoff(older_than, now)
35
+ when Integer then integer_day_cutoff(older_than, now)
36
+ else
37
+ raise ArgumentError, "older_than must be a Duration, Time, or Integer days: #{older_than.inspect}"
38
+ end
39
+ raise ArgumentError, "older_than cutoff must be before now: #{cutoff.inspect}" unless cutoff < now
40
+
41
+ cutoff
42
+ end
43
+
44
+ def duration_cutoff(duration, now)
45
+ raise ArgumentError, "older_than duration must be positive: #{duration.inspect}" unless duration.to_i.positive?
46
+
47
+ now - duration
48
+ end
49
+
50
+ def integer_day_cutoff(days, now)
51
+ raise ArgumentError, "older_than days must be positive: #{days.inspect}" unless days.positive?
52
+
53
+ now - (days * 86_400)
31
54
  end
32
55
  end
33
56
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ class Dispatcher
8
+ class << self
9
+ def save(event)
10
+ config = LlmCostTracker.configuration
11
+ case config.storage_backend
12
+ when :log then log_event(event, config)
13
+ when :active_record then active_record_save(event)
14
+ when :custom then custom_save(event, config)
15
+ end
16
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
17
+ raise
18
+ rescue StandardError => e
19
+ handle_error(e)
20
+ false
21
+ end
22
+
23
+ private
24
+
25
+ def log_event(event, config)
26
+ message = "#{event.provider}/#{event.model} " \
27
+ "tokens=#{event.total_tokens} " \
28
+ "cost=#{log_cost_label(event)}"
29
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
30
+ message += " stream=#{event.stream}" if event.stream
31
+ message += " source=#{event.usage_source}" if event.usage_source
32
+ message += " tags=#{event.tags}" unless event.tags.empty?
33
+
34
+ Logging.log(config.log_level, message)
35
+ event
36
+ end
37
+
38
+ def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
39
+
40
+ def active_record_save(event)
41
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
42
+ require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
43
+
44
+ ActiveRecordStore.save(event)
45
+ event
46
+ rescue LoadError => e
47
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
48
+ end
49
+
50
+ def custom_save(event, config)
51
+ result = config.custom_storage&.call(event)
52
+ result == false ? false : event
53
+ end
54
+
55
+ def handle_error(error)
56
+ case LlmCostTracker.configuration.storage_error_behavior
57
+ when :ignore
58
+ nil
59
+ when :warn
60
+ Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
61
+ when :raise
62
+ raise StorageError, error
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module StreamCapture
5
+ LIMIT_BYTES = 1_048_576
6
+ end
7
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "monitor"
4
5
 
6
+ require_relative "stream_capture"
5
7
  require_relative "value_helpers"
6
8
 
7
9
  module LlmCostTracker
@@ -16,6 +18,8 @@ module LlmCostTracker
16
18
  @pricing_mode = pricing_mode
17
19
  @metadata = ValueHelpers.deep_dup(metadata || {})
18
20
  @events = []
21
+ @captured_bytes = 0
22
+ @overflowed = false
19
23
  @explicit_usage = nil
20
24
  @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
25
  @finished = false
@@ -45,7 +49,7 @@ module LlmCostTracker
45
49
  def event(data, type: nil)
46
50
  @monitor.synchronize do
47
51
  ensure_open!
48
- @events << { event: type, data: ValueHelpers.deep_dup(data) } unless data.nil?
52
+ capture_event(data, type: type) unless data.nil?
49
53
  end
50
54
  self
51
55
  end
@@ -71,6 +75,7 @@ module LlmCostTracker
71
75
  @finished = true
72
76
  {
73
77
  events: @events.dup,
78
+ overflowed: @overflowed,
74
79
  explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
75
80
  model: @model,
76
81
  latency_ms: @latency_ms,
@@ -105,6 +110,7 @@ module LlmCostTracker
105
110
 
106
111
  def build_parsed_usage(snapshot)
107
112
  return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
113
+ return build_unknown_usage(snapshot) if snapshot[:overflowed]
108
114
 
109
115
  parsed = Parsers::Registry.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
110
116
  return finalize(parsed, snapshot) if parsed
@@ -157,6 +163,24 @@ module LlmCostTracker
157
163
  )
158
164
  end
159
165
 
166
+ def capture_event(data, type:)
167
+ copied = ValueHelpers.deep_dup(data)
168
+ size = event_bytes(copied, type)
169
+ if @captured_bytes + size <= StreamCapture::LIMIT_BYTES
170
+ @events << { event: type, data: copied }
171
+ @captured_bytes += size
172
+ else
173
+ @overflowed = true
174
+ @events.clear
175
+ end
176
+ end
177
+
178
+ def event_bytes(data, type)
179
+ JSON.generate(event: type, data: data).bytesize
180
+ rescue JSON::GeneratorError, TypeError
181
+ type.to_s.bytesize + data.to_s.bytesize
182
+ end
183
+
160
184
  def error_metadata(errored) = errored ? { stream_errored: true } : {}
161
185
 
162
186
  def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module TagSanitizer
7
+ REDACTED_VALUE = "[REDACTED]"
8
+
9
+ class << self
10
+ def call(tags, config: LlmCostTracker.configuration)
11
+ tags = (tags || {}).to_h
12
+ tags.first(max_tag_count(config)).each_with_object({}) do |(key, value), sanitized|
13
+ sanitized[key] = sanitized_value(key, value, config)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def sanitized_value(key, value, config)
20
+ return REDACTED_VALUE if redacted_key?(key, config)
21
+
22
+ string = value_string(value)
23
+ return value if string.bytesize <= max_tag_value_bytesize(config)
24
+
25
+ truncate_bytes(string, max_tag_value_bytesize(config))
26
+ end
27
+
28
+ def redacted_key?(key, config)
29
+ normalized = normalized_key(key)
30
+ redacted_keys(config).any? do |candidate|
31
+ redacted_key_component?(normalized, candidate)
32
+ end
33
+ end
34
+
35
+ def redacted_keys(config)
36
+ Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
37
+ end
38
+
39
+ def normalized_key(key)
40
+ key.to_s
41
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
42
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
43
+ .downcase
44
+ .gsub(/[^a-z0-9]+/, "_")
45
+ .gsub(/_+/, "_")
46
+ .delete_prefix("_")
47
+ .delete_suffix("_")
48
+ end
49
+
50
+ def redacted_key_component?(key, candidate)
51
+ key == candidate ||
52
+ key.start_with?("#{candidate}_") ||
53
+ key.end_with?("_#{candidate}") ||
54
+ key.include?("_#{candidate}_")
55
+ end
56
+
57
+ def value_string(value)
58
+ case value
59
+ when Hash, Array
60
+ JSON.generate(value)
61
+ else
62
+ value.to_s
63
+ end
64
+ rescue JSON::GeneratorError, TypeError
65
+ value.to_s
66
+ end
67
+
68
+ def truncate_bytes(string, limit)
69
+ string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
70
+ end
71
+
72
+ def max_tag_count(config)
73
+ [config.max_tag_count.to_i, 0].max
74
+ end
75
+
76
+ def max_tag_value_bytesize(config)
77
+ [config.max_tag_value_bytesize.to_i, 0].max
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "logging"
3
+ require_relative "storage/dispatcher"
4
4
 
5
5
  module LlmCostTracker
6
6
  class Tracker
7
7
  EVENT_NAME = "llm_request.llm_cost_tracker"
8
8
 
9
- USAGE_SOURCES = %i[response stream_final sdk_response manual unknown].freeze
9
+ USAGE_SOURCES = %i[response stream_final sdk_response ruby_llm manual unknown].freeze
10
10
 
11
11
  class << self
12
12
  def enforce_budget!
@@ -39,7 +39,7 @@ module LlmCostTracker
39
39
 
40
40
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
41
41
 
42
- stored = store(event)
42
+ stored = Storage::Dispatcher.save(event)
43
43
  Budget.check!(event) unless stored == false
44
44
 
45
45
  event
@@ -84,7 +84,7 @@ module LlmCostTracker
84
84
  hidden_output_tokens: usage[:hidden_output_tokens],
85
85
  pricing_mode: usage[:pricing_mode],
86
86
  cost: cost_data,
87
- tags: LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)).freeze,
87
+ tags: sanitized_tags(metadata).freeze,
88
88
  latency_ms: normalized_latency_ms(latency_ms),
89
89
  stream: stream ? true : false,
90
90
  usage_source: normalized_usage_source(usage_source),
@@ -93,64 +93,12 @@ module LlmCostTracker
93
93
  )
94
94
  end
95
95
 
96
- def store(event)
97
- config = LlmCostTracker.configuration
98
- case config.storage_backend
99
- when :log then log_event(event, config)
100
- when :active_record then active_record_save(event)
101
- when :custom then custom_save(event, config)
102
- end
103
- rescue BudgetExceededError, UnknownPricingError
104
- raise
105
- rescue StandardError => e
106
- handle_storage_error(e)
107
- false
108
- end
109
-
110
- def log_event(event, config)
111
- message = "#{event.provider}/#{event.model} " \
112
- "tokens=#{event.total_tokens} " \
113
- "cost=#{log_cost_label(event)}"
114
- message += " latency=#{event.latency_ms}ms" if event.latency_ms
115
- message += " stream=#{event.stream}" if event.stream
116
- message += " source=#{event.usage_source}" if event.usage_source
117
- message += " tags=#{event.tags}" unless event.tags.empty?
118
-
119
- Logging.log(config.log_level, message)
120
- event
121
- end
122
-
123
- def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
124
-
125
- def active_record_save(event)
126
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
127
- require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
128
-
129
- Storage::ActiveRecordStore.save(event)
130
- event
131
- rescue LoadError => e
132
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
133
- end
134
-
135
- def custom_save(event, config)
136
- result = config.custom_storage&.call(event)
137
- result == false ? false : event
138
- end
96
+ def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
139
97
 
140
- def handle_storage_error(error)
141
- case LlmCostTracker.configuration.storage_error_behavior
142
- when :ignore
143
- nil
144
- when :warn
145
- Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
146
- when :raise
147
- storage_error = StorageError.new(error)
148
- raise storage_error
149
- end
98
+ def sanitized_tags(metadata)
99
+ LlmCostTracker::TagSanitizer.call(LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)))
150
100
  end
151
101
 
152
- def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
153
-
154
102
  def normalized_usage_source(value)
155
103
  return nil if value.nil?
156
104
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
@@ -30,6 +30,7 @@ require_relative "llm_cost_tracker/budget"
30
30
  require_relative "llm_cost_tracker/unknown_pricing"
31
31
  require_relative "llm_cost_tracker/event_metadata"
32
32
  require_relative "llm_cost_tracker/tag_context"
33
+ require_relative "llm_cost_tracker/tag_sanitizer"
33
34
  require_relative "llm_cost_tracker/tags_column"
34
35
  require_relative "llm_cost_tracker/tag_key"
35
36
  require_relative "llm_cost_tracker/tag_query"