llm_cost_tracker 0.4.1 → 0.5.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +182 -100
  4. data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
  5. data/lib/llm_cost_tracker/configuration.rb +10 -5
  6. data/lib/llm_cost_tracker/doctor.rb +166 -0
  7. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
  10. data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
  11. data/lib/llm_cost_tracker/integrations/base.rb +72 -0
  12. data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
  13. data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
  14. data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
  15. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -3
  16. data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
  17. data/lib/llm_cost_tracker/parsers/base.rb +1 -1
  18. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  19. data/lib/llm_cost_tracker/price_freshness.rb +38 -0
  20. data/lib/llm_cost_tracker/price_registry.rb +14 -0
  21. data/lib/llm_cost_tracker/price_sync/fetcher.rb +2 -1
  22. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +4 -2
  23. data/lib/llm_cost_tracker/price_sync.rb +10 -0
  24. data/lib/llm_cost_tracker/prices.json +394 -41
  25. data/lib/llm_cost_tracker/pricing.rb +8 -1
  26. data/lib/llm_cost_tracker/request_url.rb +20 -0
  27. data/lib/llm_cost_tracker/stream_collector.rb +3 -3
  28. data/lib/llm_cost_tracker/tag_context.rb +52 -0
  29. data/lib/llm_cost_tracker/tracker.rb +5 -2
  30. data/lib/llm_cost_tracker/version.rb +1 -1
  31. data/lib/llm_cost_tracker.rb +14 -4
  32. data/lib/tasks/llm_cost_tracker.rake +21 -3
  33. metadata +12 -3
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
@@ -2,9 +2,12 @@
2
2
 
3
3
  require_relative "errors"
4
4
  require_relative "value_helpers"
5
+ require_relative "configuration/instrumentation"
5
6
 
6
7
  module LlmCostTracker
7
8
  class Configuration
9
+ include ConfigurationInstrumentation
10
+
8
11
  OPENAI_COMPATIBLE_PROVIDERS = {
9
12
  "openrouter.ai" => "openrouter",
10
13
  "api.deepseek.com" => "deepseek"
@@ -36,6 +39,7 @@ module LlmCostTracker
36
39
  :budget_exceeded_behavior,
37
40
  :default_tags,
38
41
  :pricing_overrides,
42
+ :instrumented_integrations,
39
43
  :report_tag_breakdowns,
40
44
  :storage_backend,
41
45
  :storage_error_behavior,
@@ -58,6 +62,7 @@ module LlmCostTracker
58
62
  @log_level = :info
59
63
  @prices_file = nil
60
64
  @pricing_overrides = {}
65
+ @instrumented_integrations = []
61
66
  @report_tag_breakdowns = []
62
67
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
63
68
  @finalized = false
@@ -97,13 +102,10 @@ module LlmCostTracker
97
102
  end
98
103
  end
99
104
 
100
- def normalize_openai_compatible_providers!
101
- self.openai_compatible_providers = openai_compatible_providers
102
- end
103
-
104
105
  def finalize!
105
106
  @default_tags = ValueHelpers.deep_freeze(@default_tags || {})
106
107
  @pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
108
+ @instrumented_integrations = ValueHelpers.deep_freeze(@instrumented_integrations || [])
107
109
  @report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
108
110
  @openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
109
111
  @finalized = true
@@ -116,6 +118,10 @@ module LlmCostTracker
116
118
  copy = dup
117
119
  copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
118
120
  copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
121
+ copy.instance_variable_set(
122
+ :@instrumented_integrations,
123
+ ValueHelpers.deep_dup(@instrumented_integrations || [])
124
+ )
119
125
  copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
120
126
  copy.instance_variable_set(
121
127
  :@openai_compatible_providers,
@@ -126,7 +132,6 @@ module LlmCostTracker
126
132
  end
127
133
 
128
134
  def active_record? = storage_backend == :active_record
129
- def log? = storage_backend == :log
130
135
 
131
136
  private
132
137
 
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "price_freshness"
4
+
5
+ module LlmCostTracker
6
+ class Doctor
7
+ Check = Data.define(:status, :name, :message)
8
+ CORE_COLUMNS = %w[provider model input_tokens output_tokens total_tokens total_cost tags tracked_at].freeze
9
+ FEATURE_COLUMNS = {
10
+ "latency_ms" => "bin/rails generate llm_cost_tracker:add_latency_ms",
11
+ "stream" => "bin/rails generate llm_cost_tracker:add_streaming",
12
+ "usage_source" => "bin/rails generate llm_cost_tracker:add_streaming",
13
+ "provider_response_id" => "bin/rails generate llm_cost_tracker:add_provider_response_id",
14
+ "cache_read_input_tokens" => "bin/rails generate llm_cost_tracker:add_usage_breakdown",
15
+ "cache_write_input_tokens" => "bin/rails generate llm_cost_tracker:add_usage_breakdown",
16
+ "hidden_output_tokens" => "bin/rails generate llm_cost_tracker:add_usage_breakdown",
17
+ "pricing_mode" => "bin/rails generate llm_cost_tracker:add_usage_breakdown"
18
+ }.freeze
19
+
20
+ class << self
21
+ def call = new.checks
22
+
23
+ def report(checks = call)
24
+ (["LLM Cost Tracker doctor"] + checks.map { |check| format_check(check) }).join("\n")
25
+ end
26
+
27
+ def healthy?(checks = call)
28
+ checks.none? { |check| check.status == :error }
29
+ end
30
+
31
+ private
32
+
33
+ def format_check(check)
34
+ "[#{check.status}] #{check.name}: #{check.message}"
35
+ end
36
+ end
37
+
38
+ def checks
39
+ [
40
+ configuration_check,
41
+ *integration_checks,
42
+ active_record_check,
43
+ table_check,
44
+ column_check,
45
+ period_totals_check,
46
+ prices_check,
47
+ calls_check
48
+ ].compact
49
+ end
50
+
51
+ private
52
+
53
+ def configuration_check
54
+ Check.new(:ok, "configuration", "storage_backend=#{LlmCostTracker.configuration.storage_backend.inspect}")
55
+ end
56
+
57
+ def integration_checks
58
+ LlmCostTracker::Integrations.checks.map do |check|
59
+ Check.new(check.status, check.name.to_s, check.message)
60
+ end
61
+ end
62
+
63
+ def active_record_check
64
+ return Check.new(:ok, "storage", "ActiveRecord storage is disabled") unless active_record_storage?
65
+ return Check.new(:ok, "active_record", "available") if active_record_available?
66
+
67
+ Check.new(:error, "active_record", "unavailable; add ActiveRecord/Rails or change storage_backend")
68
+ end
69
+
70
+ def table_check
71
+ return unless active_record_storage? && active_record_available?
72
+ return Check.new(:ok, "llm_api_calls", "table exists") if llm_api_calls_table?
73
+
74
+ Check.new(
75
+ :error,
76
+ "llm_api_calls",
77
+ "missing; run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
78
+ )
79
+ end
80
+
81
+ def column_check
82
+ return unless active_record_storage? && llm_api_calls_table?
83
+
84
+ columns = column_names("llm_api_calls")
85
+ missing_core = CORE_COLUMNS - columns
86
+ missing_features = FEATURE_COLUMNS.keys - columns
87
+ if missing_core.any?
88
+ return Check.new(:error, "llm_api_calls columns", "missing core columns: #{missing_core.join(', ')}")
89
+ end
90
+ if missing_features.any?
91
+ return Check.new(
92
+ :warn,
93
+ "llm_api_calls columns",
94
+ "missing optional columns; run #{feature_generators(missing_features).join(' && ')}"
95
+ )
96
+ end
97
+
98
+ Check.new(:ok, "llm_api_calls columns", "current")
99
+ end
100
+
101
+ def period_totals_check
102
+ return unless active_record_storage? && llm_api_calls_table?
103
+ if table_exists?("llm_cost_tracker_period_totals")
104
+ return Check.new(:ok, "period totals", "llm_cost_tracker_period_totals exists")
105
+ end
106
+
107
+ Check.new(:warn, "period totals", "missing; budget preflight falls back to llm_api_calls sums")
108
+ end
109
+
110
+ def prices_check
111
+ path = LlmCostTracker.configuration.prices_file
112
+ unless path
113
+ return Check.new(
114
+ :warn,
115
+ "prices",
116
+ "using bundled prices updated_at=#{builtin_prices_updated_at}; configure prices_file for production"
117
+ )
118
+ end
119
+
120
+ count = LlmCostTracker::PriceRegistry.file_prices(path).size
121
+ metadata = LlmCostTracker::PriceRegistry.file_metadata(path)
122
+ status, freshness = LlmCostTracker::PriceFreshness.call(metadata)
123
+ Check.new(status, "prices", "loaded #{count} models from #{path}; #{freshness}")
124
+ rescue LlmCostTracker::Error => e
125
+ Check.new(:error, "prices", e.message)
126
+ end
127
+
128
+ def calls_check
129
+ return unless active_record_storage? && llm_api_calls_table?
130
+
131
+ count = LlmCostTracker::LlmApiCall.count
132
+ return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
133
+
134
+ latest = LlmCostTracker::LlmApiCall.maximum(:tracked_at)&.utc&.iso8601
135
+ Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
136
+ end
137
+
138
+ def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
139
+
140
+ def active_record_available?
141
+ require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
142
+ LlmCostTracker::LlmApiCall.connection
143
+ true
144
+ rescue LoadError, StandardError
145
+ false
146
+ end
147
+
148
+ def llm_api_calls_table?
149
+ active_record_available? && table_exists?("llm_api_calls")
150
+ end
151
+
152
+ def table_exists?(name)
153
+ LlmCostTracker::LlmApiCall.connection.data_source_exists?(name)
154
+ rescue StandardError
155
+ false
156
+ end
157
+
158
+ def column_names(table) = LlmCostTracker::LlmApiCall.connection.columns(table).map(&:name)
159
+
160
+ def feature_generators(columns) = columns.map { |column| FEATURE_COLUMNS.fetch(column) }.uniq
161
+
162
+ def builtin_prices_updated_at
163
+ LlmCostTracker::Pricing.metadata.fetch("updated_at", "unknown")
164
+ end
165
+ end
166
+ end
@@ -11,6 +11,8 @@ module LlmCostTracker
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
13
  desc "Creates the LlmCostTracker migration and initializer"
14
+ class_option :dashboard, type: :boolean, default: false
15
+ class_option :prices, type: :boolean, default: false
14
16
 
15
17
  def create_migration_file
16
18
  migration_template(
@@ -26,11 +28,42 @@ module LlmCostTracker
26
28
  )
27
29
  end
28
30
 
31
+ def create_prices_file
32
+ return unless options[:prices]
33
+
34
+ invoke "llm_cost_tracker:prices"
35
+ end
36
+
37
+ def mount_engine
38
+ return unless options[:dashboard]
39
+
40
+ add_engine_require
41
+ route %(mount LlmCostTracker::Engine => "/llm-costs")
42
+ end
43
+
29
44
  private
30
45
 
31
46
  def migration_version
32
47
  "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
33
48
  end
49
+
50
+ def add_engine_require
51
+ return unless File.exist?("config/application.rb")
52
+
53
+ contents = File.read("config/application.rb")
54
+ return if contents.include?(%(require "llm_cost_tracker/engine"))
55
+
56
+ unless contents.include?(%(require "rails/all"\n))
57
+ prepend_to_file("config/application.rb", %(require "llm_cost_tracker/engine"\n))
58
+ return
59
+ end
60
+
61
+ inject_into_file(
62
+ "config/application.rb",
63
+ %(require "llm_cost_tracker/engine"\n),
64
+ after: %(require "rails/all"\n)
65
+ )
66
+ end
34
67
  end
35
68
  end
36
69
  end
@@ -2,17 +2,23 @@
2
2
 
3
3
  require "rails/generators"
4
4
 
5
+ require_relative "../../price_registry"
6
+ require_relative "../../price_sync/registry_loader"
7
+ require_relative "../../price_sync/registry_writer"
8
+
5
9
  module LlmCostTracker
6
10
  module Generators
7
11
  class PricesGenerator < Rails::Generators::Base
8
- source_root File.expand_path("templates", __dir__)
9
-
10
- desc "Creates a local LlmCostTracker price override file"
12
+ desc "Creates a local LLM Cost Tracker price snapshot"
11
13
 
12
14
  def create_prices_file
13
- template(
14
- "llm_cost_tracker_prices.yml.erb",
15
- "config/llm_cost_tracker_prices.yml"
15
+ registry = LlmCostTracker::PriceSync::RegistryLoader.new.call(
16
+ path: LlmCostTracker::PriceRegistry::DEFAULT_PRICES_PATH,
17
+ seed_path: LlmCostTracker::PriceRegistry::DEFAULT_PRICES_PATH
18
+ )
19
+ LlmCostTracker::PriceSync::RegistryWriter.new.call(
20
+ path: File.join(destination_root, "config/llm_cost_tracker_prices.yml"),
21
+ registry: registry
16
22
  )
17
23
  end
18
24
  end
@@ -1,42 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  LlmCostTracker.configure do |config|
4
- # Enable/disable tracking
4
+ # Set to false to temporarily disable tracking without removing middleware.
5
5
  config.enabled = true
6
6
 
7
- # Storage backend: :log, :active_record, or :custom
7
+ # :active_record stores events in llm_api_calls for dashboards, reports, and shared budgets.
8
+ # Other options: :log for local logging, :custom for your own storage callable.
8
9
  config.storage_backend = :active_record
9
10
 
10
- # Default tags added to every tracked event
11
- # config.default_tags = { environment: Rails.env, app: "my_app" }
11
+ # Tags are merged into every event. Use a callable for request/job-time context.
12
+ config.default_tags = -> { { environment: Rails.env } }
12
13
 
13
- # Monthly budget in USD. Set to nil to disable budget alerts.
14
+ # Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
15
+ # Enable only the SDKs your app already uses.
16
+ # config.instrument :openai
17
+ # config.instrument :anthropic
18
+
19
+ # Budget behavior: :notify calls on_budget_exceeded, :raise raises after recording,
20
+ # :block_requests preflights monthly/daily budgets before supported requests.
21
+ config.budget_exceeded_behavior = :notify
22
+
23
+ # Storage failures are non-fatal by default so LLM responses can still return.
24
+ # Use :raise if failed ledger writes should fail the request/job.
25
+ config.storage_error_behavior = :warn
26
+
27
+ # Unknown pricing records token usage with nil cost by default. Use :raise if
28
+ # every model must have known pricing before it can be used.
29
+ config.unknown_pricing_behavior = :warn
30
+
31
+ # Used only by the :log storage backend.
32
+ config.log_level = :info
33
+ <% if options[:prices] -%>
34
+
35
+ # Local JSON/YAML pricing file generated by --prices. Keep it in source control
36
+ # and refresh it with bin/rails llm_cost_tracker:prices:sync.
37
+ config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
38
+ <% end -%>
39
+
40
+ # Cumulative monthly/daily budgets and a single-call ceiling, in USD.
14
41
  # config.monthly_budget = 100.00
15
42
  # config.daily_budget = 10.00
16
43
  # config.per_call_budget = 1.00
17
- # config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
18
44
 
19
- # What to do when storage fails.
20
- # config.storage_error_behavior = :warn # :ignore, :warn, or :raise
21
-
22
- # What to do when a model has no built-in price and no pricing_overrides entry.
23
- # config.unknown_pricing_behavior = :warn # :ignore, :warn, or :raise
24
-
25
- # Callback when monthly budget is exceeded.
45
+ # Called when :notify is selected and a monthly, daily, or per-call budget is exceeded.
26
46
  # config.on_budget_exceeded = ->(data) {
27
- # Rails.logger.warn "[LlmCostTracker] Budget exceeded! " \
28
- # "#{data[:budget_type]} total: $#{data[:total]}, Budget: $#{data[:budget]}"
29
- # # Or send a Slack notification, email, etc.
47
+ # Rails.logger.warn(
48
+ # "LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}"
49
+ # )
30
50
  # }
31
51
 
32
- # Load a local JSON/YAML price table that overrides built-in pricing.
33
- # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.json")
34
-
35
- # Override pricing for specific models in Ruby (per 1M tokens, USD).
52
+ # Local pricing table and small Ruby-side overrides. Prices are USD per 1M tokens.
53
+ # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
36
54
  # config.pricing_overrides = {
37
55
  # "my-custom-model" => { input: 1.00, output: 2.00 }
38
56
  # }
39
57
 
40
- # OpenAI-compatible APIs. OpenRouter and DeepSeek are included by default.
58
+ # Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
59
+ # for bin/rails llm_cost_tracker:report.
41
60
  # config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
61
+ # config.report_tag_breakdowns = %w[feature user_id]
62
+
63
+ # Use :custom when you want to send events to your own sink instead of ActiveRecord.
64
+ # Return false from custom_storage to skip budget checks for that event.
65
+ # config.storage_backend = :custom
66
+ # config.custom_storage = ->(event) {
67
+ # Rails.logger.info(
68
+ # provider: event.provider,
69
+ # model: event.model,
70
+ # total_cost: event.cost&.total_cost,
71
+ # tags: event.tags
72
+ # )
73
+ # }
42
74
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module LlmCostTracker
6
+ module Integrations
7
+ module Anthropic
8
+ extend Base
9
+
10
+ class << self
11
+ def integration_name = :anthropic
12
+
13
+ def target_patches
14
+ [
15
+ [constant("Anthropic::Resources::Messages"), MessagesPatch],
16
+ [constant("Anthropic::Resources::Beta::Messages"), MessagesPatch]
17
+ ]
18
+ end
19
+
20
+ def record_message(message, request:, latency_ms:)
21
+ return unless active?
22
+
23
+ record_safely do
24
+ usage = ObjectReader.first(message, :usage)
25
+ next unless usage
26
+
27
+ input_tokens = ObjectReader.first(usage, :input_tokens)
28
+ output_tokens = ObjectReader.first(usage, :output_tokens)
29
+ next if input_tokens.nil? && output_tokens.nil?
30
+
31
+ LlmCostTracker::Tracker.record(
32
+ provider: "anthropic",
33
+ model: ObjectReader.first(message, :model) || request[:model],
34
+ input_tokens: ObjectReader.integer(input_tokens),
35
+ output_tokens: ObjectReader.integer(output_tokens),
36
+ latency_ms: latency_ms,
37
+ usage_source: :sdk_response,
38
+ provider_response_id: ObjectReader.first(message, :id),
39
+ metadata: usage_metadata(usage)
40
+ )
41
+ end
42
+ end
43
+
44
+ def usage_metadata(usage)
45
+ {
46
+ cache_read_input_tokens: ObjectReader.integer(ObjectReader.first(usage, :cache_read_input_tokens)),
47
+ cache_write_input_tokens: ObjectReader.integer(ObjectReader.first(usage, :cache_creation_input_tokens)),
48
+ hidden_output_tokens: hidden_output_tokens(usage)
49
+ }
50
+ end
51
+
52
+ def hidden_output_tokens(usage)
53
+ ObjectReader.integer(
54
+ ObjectReader.first(usage, :thinking_tokens, :thinking_output_tokens) ||
55
+ ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens)
56
+ )
57
+ end
58
+ end
59
+
60
+ module MessagesPatch
61
+ def create(*args, **kwargs)
62
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ LlmCostTracker::Integrations::Anthropic.enforce_budget!
64
+ message = super
65
+ LlmCostTracker::Integrations::Anthropic.record_message(
66
+ message,
67
+ request: LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs),
68
+ latency_ms: LlmCostTracker::Integrations::Anthropic.elapsed_ms(started_at)
69
+ )
70
+ message
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+ require_relative "object_reader"
5
+
6
+ module LlmCostTracker
7
+ module Integrations
8
+ module Base
9
+ Result = Data.define(:name, :status, :message)
10
+
11
+ def active?
12
+ LlmCostTracker.configuration.instrumented?(integration_name)
13
+ end
14
+
15
+ def install
16
+ target_patches.each { |target, patch| install_patch(target, patch) }
17
+ end
18
+
19
+ def status
20
+ name = integration_name
21
+ installed = target_patches.count { |target, patch| patch_installed?(target, patch) }
22
+ available = target_patches.count { |target, _patch| target }
23
+ return Result.new(name, :ok, "#{name} integration installed") if installed.positive?
24
+ return Result.new(name, :warn, "#{name} SDK classes are not loaded") if available.zero?
25
+
26
+ Result.new(name, :warn, "#{name} integration is enabled but not installed")
27
+ end
28
+
29
+ def elapsed_ms(started_at)
30
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
31
+ end
32
+
33
+ def enforce_budget!
34
+ LlmCostTracker::Tracker.enforce_budget! if active?
35
+ end
36
+
37
+ def record_safely
38
+ yield
39
+ rescue LlmCostTracker::Error
40
+ raise
41
+ rescue StandardError => e
42
+ Logging.warn("#{integration_name} integration failed to record usage: #{e.class}: #{e.message}")
43
+ end
44
+
45
+ def request_params(args, kwargs)
46
+ params = args.first.is_a?(Hash) ? args.first : {}
47
+ params.merge(kwargs)
48
+ end
49
+
50
+ def constant(path)
51
+ path.to_s.split("::").reduce(Object) do |scope, const_name|
52
+ return nil unless scope.const_defined?(const_name, false)
53
+
54
+ scope.const_get(const_name, false)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def install_patch(target, patch)
61
+ return unless target
62
+ return if patch_installed?(target, patch)
63
+
64
+ target.prepend(patch)
65
+ end
66
+
67
+ def patch_installed?(target, patch)
68
+ target&.ancestors&.include?(patch)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Integrations
5
+ module ObjectReader
6
+ module_function
7
+
8
+ def first(object, *keys)
9
+ keys.each do |key|
10
+ value = read(object, key)
11
+ return value unless value.nil?
12
+ end
13
+ nil
14
+ end
15
+
16
+ def nested(object, *path)
17
+ path.reduce(object) do |current, key|
18
+ return nil if current.nil?
19
+
20
+ read(current, key)
21
+ end
22
+ end
23
+
24
+ def read(object, key)
25
+ return nil if object.nil?
26
+
27
+ read_hash(object, key) || read_method(object, key) || read_index(object, key)
28
+ end
29
+
30
+ def integer(value)
31
+ value.nil? ? 0 : value.to_i
32
+ end
33
+
34
+ def read_hash(object, key)
35
+ return unless object.respond_to?(:key?)
36
+
37
+ return object[key] if object.key?(key)
38
+
39
+ string_key = key.to_s
40
+ object[string_key] if object.key?(string_key)
41
+ end
42
+
43
+ def read_method(object, key)
44
+ object.public_send(key) if object.respond_to?(key)
45
+ end
46
+
47
+ def read_index(object, key)
48
+ return unless object.respond_to?(:[])
49
+
50
+ object[key]
51
+ rescue IndexError, TypeError, NoMethodError
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end