llm_cost_tracker 0.1.2 → 0.1.4

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +149 -32
  4. data/lib/llm_cost_tracker/budget.rb +7 -19
  5. data/lib/llm_cost_tracker/configuration.rb +52 -10
  6. data/lib/llm_cost_tracker/cost.rb +15 -0
  7. data/lib/llm_cost_tracker/event.rb +24 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +20 -0
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +36 -0
  10. data/lib/llm_cost_tracker/llm_api_call.rb +56 -44
  11. data/lib/llm_cost_tracker/logging.rb +44 -0
  12. data/lib/llm_cost_tracker/middleware/faraday.rb +15 -12
  13. data/lib/llm_cost_tracker/parsed_usage.rb +45 -0
  14. data/lib/llm_cost_tracker/parsers/anthropic.rb +2 -3
  15. data/lib/llm_cost_tracker/parsers/base.rb +2 -0
  16. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -4
  17. data/lib/llm_cost_tracker/parsers/openai.rb +4 -22
  18. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -8
  19. data/lib/llm_cost_tracker/parsers/openai_usage.rb +33 -0
  20. data/lib/llm_cost_tracker/price_registry.rb +36 -6
  21. data/lib/llm_cost_tracker/pricing.rb +36 -10
  22. data/lib/llm_cost_tracker/railtie.rb +5 -0
  23. data/lib/llm_cost_tracker/report.rb +29 -0
  24. data/lib/llm_cost_tracker/report_data.rb +81 -0
  25. data/lib/llm_cost_tracker/report_formatter.rb +65 -0
  26. data/lib/llm_cost_tracker/storage/active_record_backend.rb +19 -0
  27. data/lib/llm_cost_tracker/storage/active_record_store.rb +11 -11
  28. data/lib/llm_cost_tracker/storage/backends.rb +26 -0
  29. data/lib/llm_cost_tracker/storage/custom_backend.rb +16 -0
  30. data/lib/llm_cost_tracker/storage/log_backend.rb +28 -0
  31. data/lib/llm_cost_tracker/tag_accessors.rb +15 -0
  32. data/lib/llm_cost_tracker/tag_query.rb +38 -0
  33. data/lib/llm_cost_tracker/tags_column.rb +16 -0
  34. data/lib/llm_cost_tracker/tracker.rb +18 -67
  35. data/lib/llm_cost_tracker/unknown_pricing.rb +8 -15
  36. data/lib/llm_cost_tracker/value_object.rb +45 -0
  37. data/lib/llm_cost_tracker/version.rb +1 -1
  38. data/lib/llm_cost_tracker.rb +28 -13
  39. data/lib/tasks/llm_cost_tracker.rake +9 -0
  40. data/llm_cost_tracker.gemspec +1 -1
  41. metadata +22 -3
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "report_data"
4
+ require_relative "report_formatter"
5
+
6
+ module LlmCostTracker
7
+ class Report
8
+ DEFAULT_DAYS = ReportData::DEFAULT_DAYS
9
+
10
+ class << self
11
+ # Render a terminal-friendly cost report from ActiveRecord storage.
12
+ #
13
+ # @param days [Integer] Number of trailing days to include.
14
+ # @param now [Time] Report end time.
15
+ # @return [String]
16
+ def generate(days: DEFAULT_DAYS, now: Time.now.utc)
17
+ ReportFormatter.new(data(days: days, now: now)).to_s
18
+ rescue LoadError => e
19
+ "Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
20
+ rescue StandardError => e
21
+ "Unable to build LLM cost report: #{e.class}: #{e.message}"
22
+ end
23
+
24
+ def data(days: DEFAULT_DAYS, now: Time.now.utc)
25
+ ReportData.build(days: days, now: now)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "value_object"
4
+
5
+ module LlmCostTracker
6
+ TopCall = ValueObject.define(:provider, :model, :total_cost)
7
+
8
+ ReportData = ValueObject.define(
9
+ :days,
10
+ :from_time,
11
+ :to_time,
12
+ :total_cost,
13
+ :requests_count,
14
+ :average_latency_ms,
15
+ :unknown_pricing_count,
16
+ :cost_by_provider,
17
+ :cost_by_model,
18
+ :cost_by_tags,
19
+ :top_calls
20
+ )
21
+
22
+ ReportData.const_set(:DEFAULT_DAYS, 30)
23
+ ReportData.const_set(:TOP_LIMIT, 5)
24
+ ReportData.const_set(:DEFAULT_TAG_BREAKDOWNS, %w[feature].freeze)
25
+
26
+ class << ReportData
27
+ def build(days: ReportData::DEFAULT_DAYS, now: Time.now.utc)
28
+ require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
29
+
30
+ days = normalized_days(days)
31
+ scope = LlmApiCall.where(tracked_at: from_time(days, now)..now)
32
+
33
+ new(
34
+ days: days,
35
+ from_time: from_time(days, now),
36
+ to_time: now,
37
+ total_cost: scope.sum(:total_cost).to_f,
38
+ requests_count: scope.count,
39
+ average_latency_ms: average_latency_ms(scope),
40
+ unknown_pricing_count: scope.where(total_cost: nil).count,
41
+ cost_by_provider: cost_by(scope, :provider),
42
+ cost_by_model: cost_by(scope, :model),
43
+ cost_by_tags: cost_by_tags(scope, ReportData::DEFAULT_TAG_BREAKDOWNS),
44
+ top_calls: top_calls(scope)
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def normalized_days(days)
51
+ days = days.to_i
52
+ days.positive? ? days : ReportData::DEFAULT_DAYS
53
+ end
54
+
55
+ def from_time(days, now)
56
+ now - (days * 86_400)
57
+ end
58
+
59
+ def average_latency_ms(scope)
60
+ return nil unless LlmApiCall.latency_column?
61
+
62
+ scope.average(:latency_ms)&.to_f
63
+ end
64
+
65
+ def cost_by(scope, column)
66
+ scope.group(column).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
67
+ end
68
+
69
+ def cost_by_tags(scope, keys)
70
+ keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
71
+ end
72
+
73
+ def top_calls(scope)
74
+ scope
75
+ .where.not(total_cost: nil)
76
+ .order(total_cost: :desc)
77
+ .limit(ReportData::TOP_LIMIT)
78
+ .map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ReportFormatter
5
+ TOP_LIMIT = 5
6
+
7
+ def initialize(data)
8
+ @data = data
9
+ end
10
+
11
+ def to_s
12
+ lines = ["LLM Cost Report (last #{@data.days} days)", ""]
13
+ append_summary(lines)
14
+ append_cost_section(lines, "By provider", @data.cost_by_provider)
15
+ append_cost_section(lines, "By model", @data.cost_by_model)
16
+ append_tag_sections(lines)
17
+ append_top_calls(lines)
18
+ lines.join("\n")
19
+ end
20
+
21
+ private
22
+
23
+ def append_summary(lines)
24
+ lines << "Total cost: #{money(@data.total_cost)}"
25
+ lines << "Requests: #{@data.requests_count}"
26
+ lines << "Avg latency: #{average_latency}"
27
+ lines << "Unknown pricing: #{@data.unknown_pricing_count}"
28
+ end
29
+
30
+ def append_cost_section(lines, title, rows)
31
+ lines << ""
32
+ lines << "#{title}:"
33
+ return lines << " none" if rows.empty?
34
+
35
+ rows.first(TOP_LIMIT).each do |name, cost|
36
+ lines << " #{name.to_s.ljust(28)} #{money(cost)}"
37
+ end
38
+ end
39
+
40
+ def append_tag_sections(lines)
41
+ @data.cost_by_tags.each do |tag_key, rows|
42
+ append_cost_section(lines, "By tag (#{tag_key})", rows)
43
+ end
44
+ end
45
+
46
+ def append_top_calls(lines)
47
+ lines << ""
48
+ lines << "Top expensive calls:"
49
+ return lines << " none" if @data.top_calls.empty?
50
+
51
+ @data.top_calls.first(TOP_LIMIT).each do |call|
52
+ label = "#{call.provider}/#{call.model}"
53
+ lines << " #{label.ljust(32)} #{money(call.total_cost)}"
54
+ end
55
+ end
56
+
57
+ def average_latency
58
+ @data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
59
+ end
60
+
61
+ def money(value)
62
+ "$#{format('%.6f', value.to_f)}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Storage
5
+ module ActiveRecordBackend
6
+ class << self
7
+ def save(event, **_options)
8
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
9
+ require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
10
+
11
+ ActiveRecordStore.save(event)
12
+ event
13
+ rescue LoadError => e
14
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -5,21 +5,21 @@ module LlmCostTracker
5
5
  class ActiveRecordStore
6
6
  class << self
7
7
  def save(event)
8
- tags = stringify_tags(event[:tags] || {})
8
+ tags = stringify_tags(event.tags || {})
9
9
 
10
10
  attributes = {
11
- provider: event[:provider],
12
- model: event[:model],
13
- input_tokens: event[:input_tokens],
14
- output_tokens: event[:output_tokens],
15
- total_tokens: event[:total_tokens],
16
- input_cost: event.dig(:cost, :input_cost),
17
- output_cost: event.dig(:cost, :output_cost),
18
- total_cost: event.dig(:cost, :total_cost),
11
+ provider: event.provider,
12
+ model: event.model,
13
+ input_tokens: event.input_tokens,
14
+ output_tokens: event.output_tokens,
15
+ total_tokens: event.total_tokens,
16
+ input_cost: event.cost&.input_cost,
17
+ output_cost: event.cost&.output_cost,
18
+ total_cost: event.cost&.total_cost,
19
19
  tags: tags_for_storage(tags),
20
- tracked_at: event[:tracked_at]
20
+ tracked_at: event.tracked_at
21
21
  }
22
- attributes[:latency_ms] = event[:latency_ms] if model_class.latency_column?
22
+ attributes[:latency_ms] = event.latency_ms if model_class.latency_column?
23
23
 
24
24
  model_class.create!(attributes)
25
25
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "log_backend"
5
+ require_relative "active_record_backend"
6
+ require_relative "custom_backend"
7
+
8
+ module LlmCostTracker
9
+ module Storage
10
+ module Backends
11
+ MAP = {
12
+ log: LogBackend,
13
+ active_record: ActiveRecordBackend,
14
+ custom: CustomBackend
15
+ }.freeze
16
+
17
+ class << self
18
+ def fetch(name)
19
+ MAP.fetch(name.to_sym)
20
+ rescue KeyError
21
+ raise Error, "Unknown storage_backend: #{name.inspect}. Use one of: #{MAP.keys.join(', ')}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Storage
5
+ module CustomBackend
6
+ class << self
7
+ def save(event, config:)
8
+ result = config.custom_storage&.call(event)
9
+ return false if result == false
10
+
11
+ event
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ module LogBackend
8
+ class << self
9
+ def save(event, config:)
10
+ message = "#{event.provider}/#{event.model} " \
11
+ "tokens=#{event.input_tokens}+#{event.output_tokens} " \
12
+ "cost=#{cost_label(event)}"
13
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
14
+ message += " tags=#{event.tags}" unless event.tags.empty?
15
+
16
+ Logging.log(config.log_level, message)
17
+ event
18
+ end
19
+
20
+ private
21
+
22
+ def cost_label(event)
23
+ event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module TagAccessors
7
+ def parsed_tags
8
+ return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
9
+
10
+ JSON.parse(tags || "{}")
11
+ rescue JSON::ParserError
12
+ {}
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module TagQuery
7
+ class << self
8
+ def apply(model, tags)
9
+ normalized_tags = normalize_tags(tags)
10
+ return model.all if normalized_tags.empty?
11
+
12
+ return json_query(model, normalized_tags) if model.tags_json_column?
13
+
14
+ text_query(model, normalized_tags)
15
+ end
16
+
17
+ def normalize_tags(tags)
18
+ (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
19
+ end
20
+
21
+ private
22
+
23
+ def json_query(model, tags)
24
+ model.where("tags @> ?::jsonb", tags.to_json)
25
+ end
26
+
27
+ def text_query(model, tags)
28
+ tags.reduce(model.all) do |relation, (key, value)|
29
+ relation.where("tags LIKE ? ESCAPE '\\'", "%#{model.sanitize_sql_like(json_tag_fragment(key, value))}%")
30
+ end
31
+ end
32
+
33
+ def json_tag_fragment(key, value)
34
+ JSON.generate(key => value).delete_prefix("{").delete_suffix("}")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module TagsColumn
5
+ def tags_json_column?
6
+ column = columns_hash["tags"]
7
+ return false unless column
8
+
9
+ %i[json jsonb].include?(column.type) || column.sql_type.to_s.downcase == "jsonb"
10
+ end
11
+
12
+ def latency_column?
13
+ columns_hash.key?("latency_ms")
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logging"
4
+
3
5
  module LlmCostTracker
4
6
  class Tracker
5
7
  EVENT_NAME = "llm_request.llm_cost_tracker"
@@ -9,6 +11,15 @@ module LlmCostTracker
9
11
  Budget.enforce!
10
12
  end
11
13
 
14
+ # Build, notify, persist, and budget-check a single LLM usage event.
15
+ #
16
+ # @param provider [String] Provider name.
17
+ # @param model [String] Model identifier.
18
+ # @param input_tokens [Integer] Input token count.
19
+ # @param output_tokens [Integer] Output token count.
20
+ # @param metadata [Hash] Attribution tags plus provider-specific usage metadata.
21
+ # @param latency_ms [Integer, nil] Optional latency in milliseconds.
22
+ # @return [LlmCostTracker::Event]
12
23
  def record(provider:, model:, input_tokens:, output_tokens:, metadata: {}, latency_ms: nil)
13
24
  usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
14
25
 
@@ -23,20 +34,20 @@ module LlmCostTracker
23
34
 
24
35
  UnknownPricing.handle!(model) unless cost_data
25
36
 
26
- event = {
37
+ event = Event.new(
27
38
  provider: provider,
28
39
  model: model,
29
40
  input_tokens: usage[:input_tokens],
30
41
  output_tokens: usage[:output_tokens],
31
42
  total_tokens: usage[:total_tokens],
32
43
  cost: cost_data,
33
- tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)),
44
+ tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)).freeze,
34
45
  latency_ms: normalized_latency_ms(latency_ms),
35
46
  tracked_at: Time.now.utc
36
- }
47
+ )
37
48
 
38
49
  # Emit ActiveSupport::Notifications event
39
- ActiveSupport::Notifications.instrument(EVENT_NAME, event)
50
+ ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
40
51
 
41
52
  # Store based on backend
42
53
  stored = store(event)
@@ -49,17 +60,7 @@ module LlmCostTracker
49
60
 
50
61
  def store(event)
51
62
  config = LlmCostTracker.configuration
52
-
53
- case config.storage_backend
54
- when :log
55
- log_event(event)
56
- when :active_record
57
- store_active_record(event)
58
- when :custom
59
- config.custom_storage&.call(event)
60
- end
61
-
62
- true
63
+ Storage::Backends.fetch(config.storage_backend).save(event, config: config)
63
64
  rescue BudgetExceededError, UnknownPricingError
64
65
  raise
65
66
  rescue StandardError => e
@@ -67,68 +68,18 @@ module LlmCostTracker
67
68
  false
68
69
  end
69
70
 
70
- def log_event(event)
71
- cost_str = event[:cost] ? "$#{format('%.6f', event[:cost][:total_cost])}" : "unknown"
72
-
73
- message = "[LlmCostTracker] #{event[:provider]}/#{event[:model]} " \
74
- "tokens=#{event[:input_tokens]}+#{event[:output_tokens]} " \
75
- "cost=#{cost_str}"
76
- message += " latency=#{event[:latency_ms]}ms" if event[:latency_ms]
77
- message += " tags=#{event[:tags]}" unless event[:tags].empty?
78
-
79
- case LlmCostTracker.configuration.log_level
80
- when :debug
81
- Rails.logger.debug(message) if defined?(Rails)
82
- when :warn
83
- Rails.logger.warn(message) if defined?(Rails)
84
- else
85
- Rails.logger.info(message) if defined?(Rails)
86
- end
87
-
88
- # Fallback if Rails is not available
89
- warn(message) unless defined?(Rails)
90
- end
91
-
92
- def log_warning(message)
93
- message = "[LlmCostTracker] #{message}"
94
-
95
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
96
- Rails.logger.warn(message)
97
- else
98
- warn message
99
- end
100
- end
101
-
102
- def store_active_record(event)
103
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
104
- require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
105
-
106
- LlmCostTracker::Storage::ActiveRecordStore.save(event)
107
- rescue LoadError => e
108
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
109
- end
110
-
111
71
  def handle_storage_error(error)
112
- case storage_error_behavior
72
+ case LlmCostTracker.configuration.storage_error_behavior
113
73
  when :ignore
114
74
  nil
115
75
  when :warn
116
- log_warning("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
76
+ Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
117
77
  when :raise
118
78
  storage_error = StorageError.new(error)
119
79
  raise storage_error
120
80
  end
121
81
  end
122
82
 
123
- def storage_error_behavior
124
- behavior = (LlmCostTracker.configuration.storage_error_behavior || :warn).to_sym
125
- return behavior if Configuration::STORAGE_ERROR_BEHAVIORS.include?(behavior)
126
-
127
- raise Error,
128
- "Unknown storage_error_behavior: #{behavior.inspect}. " \
129
- "Use one of: #{Configuration::STORAGE_ERROR_BEHAVIORS.join(', ')}"
130
- end
131
-
132
83
  def normalized_latency_ms(latency_ms)
133
84
  return nil if latency_ms.nil?
134
85
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logging"
4
+
3
5
  module LlmCostTracker
4
6
  class UnknownPricing
5
7
  class << self
@@ -23,24 +25,15 @@ module LlmCostTracker
23
25
  end
24
26
 
25
27
  def warn_missing(model)
26
- message = "[LlmCostTracker] No pricing configured for model #{model.inspect}. " \
27
- "Cost and budget enforcement will be skipped for this event. " \
28
- "Add a pricing_overrides entry or set unknown_pricing_behavior."
29
-
30
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
31
- Rails.logger.warn(message)
32
- else
33
- Kernel.warn(message)
34
- end
28
+ Logging.warn(
29
+ "No pricing configured for model #{model.inspect}. " \
30
+ "Cost and budget guardrails will be skipped for this event. " \
31
+ "Add a pricing_overrides entry or set unknown_pricing_behavior."
32
+ )
35
33
  end
36
34
 
37
35
  def behavior
38
- behavior = (LlmCostTracker.configuration.unknown_pricing_behavior || :warn).to_sym
39
- return behavior if Configuration::UNKNOWN_PRICING_BEHAVIORS.include?(behavior)
40
-
41
- raise Error,
42
- "Unknown unknown_pricing_behavior: #{behavior.inspect}. " \
43
- "Use one of: #{Configuration::UNKNOWN_PRICING_BEHAVIORS.join(', ')}"
36
+ LlmCostTracker.configuration.unknown_pricing_behavior
44
37
  end
45
38
  end
46
39
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module ValueObject
5
+ class << self
6
+ def define(*members, &block)
7
+ klass = data_class(*members)
8
+ add_hash_like_readers(klass)
9
+ klass.class_eval(&block) if block
10
+ klass
11
+ end
12
+
13
+ private
14
+
15
+ def data_class(*members)
16
+ return Data.define(*members) if defined?(Data)
17
+
18
+ Struct.new(*members, keyword_init: true) do
19
+ def initialize(**kwargs)
20
+ super
21
+ freeze
22
+ end
23
+ end
24
+ end
25
+
26
+ def add_hash_like_readers(klass)
27
+ klass.class_eval do
28
+ def [](key)
29
+ public_send(key.to_sym)
30
+ end
31
+
32
+ def dig(key, *rest)
33
+ value = self[key]
34
+ rest.empty? ? value : value&.dig(*rest)
35
+ end
36
+
37
+ def except(*keys)
38
+ excluded = keys.map(&:to_sym)
39
+ to_h.reject { |key, _value| excluded.include?(key.to_sym) }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.4"
5
5
  end