llm_cost_tracker 0.13.0 → 0.14.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
@@ -13,8 +13,7 @@ module LlmCostTracker
13
13
  @collector = collector
14
14
  @active = active
15
15
  @finish = finish || proc { |errored| collector.finish!(errored: errored) }
16
- @finished_ref = [false]
17
- @attempted_ref = [false]
16
+ @finished = false
18
17
  @capture_failed = false
19
18
  @mutex = Mutex.new
20
19
  end
@@ -45,7 +44,6 @@ module LlmCostTracker
45
44
  )
46
45
  end
47
46
 
48
- register_orphan_finalizer
49
47
  @stream
50
48
  rescue StandardError => e
51
49
  Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
@@ -77,7 +75,7 @@ module LlmCostTracker
77
75
  yield event
78
76
  end
79
77
  end
80
- rescue StandardError
78
+ rescue Exception # rubocop:disable Lint/RescueException -- mark Interrupt/shutdown too, so it is not masked
81
79
  errored = true
82
80
  raise
83
81
  ensure
@@ -107,10 +105,9 @@ module LlmCostTracker
107
105
 
108
106
  def finish!(errored:)
109
107
  should_finish = @mutex.synchronize do
110
- @attempted_ref[0] = true
111
- next false if @finished_ref[0]
108
+ next false if @finished
112
109
 
113
- @finished_ref[0] = true
110
+ @finished = true
114
111
  true
115
112
  end
116
113
  return unless should_finish && @active.call
@@ -118,37 +115,10 @@ module LlmCostTracker
118
115
  begin
119
116
  @finish.call(errored)
120
117
  rescue StandardError
121
- @mutex.synchronize { @finished_ref[0] = false }
118
+ @mutex.synchronize { @finished = false }
122
119
  raise
123
120
  end
124
121
  end
125
-
126
- def register_orphan_finalizer
127
- finished_ref = @finished_ref
128
- attempted_ref = @attempted_ref
129
- finish_proc = @finish
130
- active_proc = @active
131
- mutex = @mutex
132
- finalizer = lambda do |_object_id|
133
- should_finish = mutex.synchronize do
134
- next false if finished_ref[0] || attempted_ref[0]
135
-
136
- finished_ref[0] = true
137
- attempted_ref[0] = true
138
- true
139
- end
140
- next unless should_finish && active_proc.call
141
-
142
- begin
143
- Rails.application.executor.wrap { finish_proc.call(false) }
144
- rescue StandardError
145
- nil
146
- end
147
- end
148
- ObjectSpace.define_finalizer(@stream, finalizer)
149
- rescue TypeError, ArgumentError
150
- nil
151
- end
152
122
  end
153
123
  end
154
124
  end
@@ -16,7 +16,7 @@ module LlmCostTracker
16
16
  "#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
17
17
  end
18
18
 
19
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
19
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
20
20
  def self.call(token_usage:,
21
21
  usage_source:,
22
22
  token_cost:,
@@ -43,9 +43,10 @@ module LlmCostTracker
43
43
  return UNKNOWN if unpriced && !priced
44
44
  return PARTIAL if unpriced
45
45
 
46
- total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
46
+ return UNKNOWN if total_cost.nil?
47
+
48
+ total_cost.zero? ? FREE : COMPLETE
47
49
  end
48
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
49
50
  end
50
51
  end
51
52
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "section"
6
+
7
+ module LlmCostTracker
8
+ class Configuration
9
+ class Budgets < Section
10
+ EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
11
+ TOTALS_SOURCES = %i[ledger cache].freeze
12
+ PER_TAG_WINDOWS = %i[daily weekly monthly].freeze
13
+ PER_TAG_OPTIONS = %i[behavior on_exceeded].freeze
14
+
15
+ attributes :monthly, :daily, :per_call, :on_exceeded
16
+ enum_attribute :exceeded_behavior, allowed: EXCEEDED_BEHAVIORS, default: :notify
17
+ enum_attribute :totals_source, allowed: TOTALS_SOURCES, default: :ledger
18
+
19
+ attr_reader :per_tag
20
+
21
+ def initialize(owner)
22
+ super
23
+ @monthly = nil
24
+ @daily = nil
25
+ @per_call = nil
26
+ @on_exceeded = nil
27
+ @per_tag = {}
28
+ self.exceeded_behavior = :notify
29
+ self.totals_source = :ledger
30
+ end
31
+
32
+ def per_tag=(value)
33
+ ensure_mutable!
34
+ @per_tag = (value || {}).to_h.to_h { |key, entry| [validated_key(key), validated_entry(key, entry)] }
35
+ end
36
+
37
+ def finalize!
38
+ @per_tag = deep_freeze(@per_tag || {})
39
+ end
40
+
41
+ private
42
+
43
+ def validated_key(key)
44
+ LlmCostTracker::Tags::Key.validate!(key, error_class: Error)
45
+ end
46
+
47
+ def validated_entry(key, entry)
48
+ raise Error, "budgets.per_tag[#{key.inspect}] must be a hash" unless entry.is_a?(Hash)
49
+
50
+ normalized = entry.to_h.transform_keys(&:to_sym)
51
+ windows, options = normalized.partition { |name, _| !PER_TAG_OPTIONS.include?(name) }.map(&:to_h)
52
+ if windows.empty?
53
+ raise Error,
54
+ "budgets.per_tag[#{key.inspect}] needs at least one of: #{PER_TAG_WINDOWS.join(', ')}"
55
+ end
56
+
57
+ {
58
+ windows: windows.to_h do |window, limit|
59
+ [validated_window(key, window), validated_limit(key, window, limit)]
60
+ end,
61
+ behavior: validated_behavior(key, options[:behavior]),
62
+ on_exceeded: options[:on_exceeded]
63
+ }
64
+ end
65
+
66
+ def validated_behavior(key, behavior)
67
+ return nil if behavior.nil?
68
+ return behavior.to_sym if EXCEEDED_BEHAVIORS.include?(behavior.to_sym)
69
+
70
+ raise Error,
71
+ "Unknown budgets.per_tag[#{key.inspect}] behavior: #{behavior.inspect}. " \
72
+ "Use one of: #{EXCEEDED_BEHAVIORS.join(', ')}"
73
+ end
74
+
75
+ def validated_window(key, window)
76
+ return window.to_sym if PER_TAG_WINDOWS.include?(window.to_sym)
77
+
78
+ raise Error,
79
+ "Unknown budgets.per_tag[#{key.inspect}] window: #{window.inspect}. " \
80
+ "Use one of: #{PER_TAG_WINDOWS.join(', ')}"
81
+ end
82
+
83
+ def validated_limit(key, window, limit)
84
+ numeric = Float(limit, exception: false)
85
+ return BigDecimal(limit.to_s) if numeric&.positive?
86
+
87
+ raise Error,
88
+ "budgets.per_tag[#{key.inspect}][#{window.inspect}] must be a positive number, " \
89
+ "got #{limit.inspect}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module LlmCostTracker
6
+ class Configuration
7
+ class Capture < Section
8
+ OPENAI_COMPATIBLE_PROVIDERS = {
9
+ "openrouter.ai" => "openrouter",
10
+ "api.deepseek.com" => "deepseek",
11
+ "api.groq.com" => "groq"
12
+ }.freeze
13
+
14
+ attributes :request_stream_usage
15
+
16
+ attr_reader :openai_compatible_providers
17
+
18
+ def initialize(owner)
19
+ super
20
+ @request_stream_usage = true
21
+ self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
22
+ end
23
+
24
+ def openai_compatible_providers=(providers)
25
+ ensure_mutable!
26
+ @openai_compatible_providers = normalize_providers(providers)
27
+ end
28
+
29
+ def finalize!
30
+ @openai_compatible_providers = deep_freeze(normalize_providers(@openai_compatible_providers))
31
+ end
32
+
33
+ private
34
+
35
+ def normalize_providers(providers)
36
+ (providers || {}).each_with_object({}) do |(host, provider), normalized|
37
+ normalized[host.to_s.downcase] = provider.to_s
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module LlmCostTracker
6
+ class Configuration
7
+ class Ingestion < Section
8
+ MODES = %i[inline async].freeze
9
+
10
+ attributes :pool_size
11
+ enum_attribute :mode, allowed: MODES, default: :inline
12
+
13
+ def initialize(owner)
14
+ super
15
+ @pool_size = nil
16
+ self.mode = :inline
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Configuration
5
+ module Mutability
6
+ private
7
+
8
+ def ensure_mutable!
9
+ return unless finalized?
10
+
11
+ raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
12
+ end
13
+
14
+ def deep_freeze(value)
15
+ case value
16
+ when Hash
17
+ value.each do |key, nested_value|
18
+ deep_freeze(key)
19
+ deep_freeze(nested_value)
20
+ end
21
+ value.frozen? ? value : value.freeze
22
+ when Array, Set
23
+ value.each { |nested_value| deep_freeze(nested_value) }
24
+ value.frozen? ? value : value.freeze
25
+ when String
26
+ value.frozen? ? value : value.freeze
27
+ else
28
+ value
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module LlmCostTracker
6
+ class Configuration
7
+ class Pricing < Section
8
+ UNKNOWN_BEHAVIORS = %i[ignore warn raise].freeze
9
+
10
+ attributes :file
11
+ enum_attribute :unknown_model_behavior, allowed: UNKNOWN_BEHAVIORS, default: :warn
12
+
13
+ attr_reader :overrides
14
+
15
+ def initialize(owner)
16
+ super
17
+ @file = nil
18
+ self.overrides = {}
19
+ self.unknown_model_behavior = :warn
20
+ end
21
+
22
+ def overrides=(value)
23
+ ensure_mutable!
24
+ @overrides = LlmCostTracker::Pricing::Registry.normalize_price_entries(
25
+ value || {}, context: "pricing_overrides"
26
+ )
27
+ rescue ArgumentError, TypeError => e
28
+ raise Error, "invalid pricing.overrides: #{e.message}"
29
+ end
30
+
31
+ def finalize!
32
+ @overrides = deep_freeze(@overrides || {})
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ require_relative "mutability"
7
+
8
+ module LlmCostTracker
9
+ class Configuration
10
+ class Section
11
+ include Mutability
12
+
13
+ class << self
14
+ def attributes(*names)
15
+ names.each do |name|
16
+ attr_reader name
17
+
18
+ define_method(:"#{name}=") do |value|
19
+ ensure_mutable!
20
+ instance_variable_set(:"@#{name}", value)
21
+ end
22
+ end
23
+ end
24
+
25
+ def enum_attribute(name, allowed:, default:)
26
+ attr_reader name
27
+
28
+ define_method(:"#{name}=") do |value|
29
+ ensure_mutable!
30
+ instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default))
31
+ end
32
+ end
33
+ end
34
+
35
+ private attr_reader :owner
36
+
37
+ delegate :finalized?, to: :owner
38
+
39
+ def initialize(owner)
40
+ @owner = owner
41
+ end
42
+
43
+ def finalize!; end
44
+
45
+ private
46
+
47
+ def section_name
48
+ self.class.name.split("::").last.underscore
49
+ end
50
+
51
+ def normalize_enum(name, value, allowed, default)
52
+ value = default if value.nil?
53
+ return value if allowed.include?(value)
54
+
55
+ raise Error, "Unknown #{section_name}.#{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module LlmCostTracker
6
+ class Configuration
7
+ class Tags < Section
8
+ DEFAULT_REDACTED_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
9
+
10
+ attributes :default, :max_count, :max_value_bytesize
11
+
12
+ attr_reader :redacted_keys, :report_breakdown_keys
13
+
14
+ def initialize(owner)
15
+ super
16
+ @default = {}
17
+ @max_count = 50
18
+ @max_value_bytesize = 1024
19
+ @redacted_keys = DEFAULT_REDACTED_KEYS.dup
20
+ @report_breakdown_keys = []
21
+ end
22
+
23
+ def redacted_keys=(value)
24
+ ensure_mutable!
25
+ @redacted_keys = Array(value).map(&:to_s)
26
+ end
27
+
28
+ def report_breakdown_keys=(value)
29
+ ensure_mutable!
30
+ @report_breakdown_keys = Array(value).map { |key| LlmCostTracker::Tags::Key.validate!(key, error_class: Error) }
31
+ end
32
+
33
+ def normalized_redacted_keys
34
+ @normalized_redacted_keys ||=
35
+ Array(@redacted_keys).map { |key| LlmCostTracker::Tags::Sanitizer.normalized_key(key) }.freeze
36
+ end
37
+
38
+ def static_sanitized_default
39
+ return nil if @default.respond_to?(:call)
40
+
41
+ @static_sanitized_default ||=
42
+ LlmCostTracker::Tags::Sanitizer.call((@default || {}).to_h, config: owner).freeze
43
+ end
44
+
45
+ def finalize!
46
+ @default = deep_freeze(@default || {})
47
+ @redacted_keys = deep_freeze(Array(@redacted_keys))
48
+ @report_breakdown_keys = deep_freeze(Array(@report_breakdown_keys))
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,89 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "deprecator"
4
5
  require_relative "pricing/registry"
5
6
  require_relative "tags/key"
7
+ require_relative "configuration/budgets"
8
+ require_relative "configuration/capture"
9
+ require_relative "configuration/ingestion"
10
+ require_relative "configuration/pricing"
11
+ require_relative "configuration/tags"
6
12
 
7
13
  module LlmCostTracker
8
14
  class Configuration
9
- OPENAI_COMPATIBLE_PROVIDERS = {
10
- "openrouter.ai" => "openrouter",
11
- "api.deepseek.com" => "deepseek",
12
- "api.groq.com" => "groq"
15
+ include Mutability
16
+
17
+ SECTIONS = {
18
+ budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
13
19
  }.freeze
14
20
 
15
- BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
16
- UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
17
- INGESTION_MODES = %i[inline async].freeze
18
- SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
19
- log_level prices_file max_tag_count max_tag_value_bytesize
20
- ingestion_pool_size auto_enable_stream_usage cache_rollups].freeze
21
- ENUM_ATTRIBUTES = {
22
- budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
- unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn],
24
- ingestion: [INGESTION_MODES, :inline]
21
+ SCALAR_ATTRIBUTES = %i[enabled].freeze
22
+
23
+ DEPRECATED_OPTIONS = {
24
+ monthly_budget: { to: %i[budgets monthly] },
25
+ daily_budget: { to: %i[budgets daily] },
26
+ per_call_budget: { to: %i[budgets per_call] },
27
+ budget_exceeded_behavior: { to: %i[budgets exceeded_behavior] },
28
+ on_budget_exceeded: { to: %i[budgets on_exceeded] },
29
+ default_tags: { to: %i[tags default] },
30
+ max_tag_count: { to: %i[tags max_count] },
31
+ max_tag_value_bytesize: { to: %i[tags max_value_bytesize] },
32
+ redacted_tag_keys: { to: %i[tags redacted_keys] },
33
+ report_tag_breakdowns: { to: %i[tags report_breakdown_keys] },
34
+ prices_file: { to: %i[pricing file] },
35
+ pricing_overrides: { to: %i[pricing overrides] },
36
+ unknown_pricing_behavior: { to: %i[pricing unknown_model_behavior] },
37
+ ingestion_pool_size: { to: %i[ingestion pool_size] },
38
+ auto_enable_stream_usage: { to: %i[capture request_stream_usage] },
39
+ openai_compatible_providers: { to: %i[capture openai_compatible_providers] },
40
+ cache_rollups: {
41
+ to: %i[budgets totals_source],
42
+ cast: ->(value) { value ? :cache : :ledger },
43
+ uncast: ->(value) { value == :cache }
44
+ },
45
+ ingestion: { to: %i[ingestion mode], writer_only: true },
46
+ log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
25
47
  }.freeze
26
- DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
27
-
28
- attr_reader(
29
- *SCALAR_ATTRIBUTES,
30
- :budget_exceeded_behavior,
31
- :ingestion,
32
- :instrumented_integrations,
33
- :pricing_overrides,
34
- :report_tag_breakdowns,
35
- :redacted_tag_keys,
36
- :unknown_pricing_behavior,
37
- :openai_compatible_providers
38
- )
48
+
49
+ attr_reader(*SCALAR_ATTRIBUTES, *SECTIONS.keys, :instrumented_integrations)
39
50
 
40
51
  def initialize
52
+ SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
41
53
  @enabled = true
42
- @default_tags = {}
43
- @on_budget_exceeded = nil
44
- @monthly_budget = nil
45
- @daily_budget = nil
46
- @per_call_budget = nil
47
- self.budget_exceeded_behavior = :notify
48
- self.unknown_pricing_behavior = :warn
49
- @log_level = :info
50
- @prices_file = nil
51
- @max_tag_count = 50
52
- @max_tag_value_bytesize = 1024
53
- @ingestion_pool_size = nil
54
- self.pricing_overrides = {}
54
+ @log_level = :info
55
55
  @instrumented_integrations = Set.new
56
- @report_tag_breakdowns = []
57
- @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
58
- self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
59
- @auto_enable_stream_usage = true
60
- self.ingestion = :inline
61
- @cache_rollups = false
62
56
  @finalized = false
63
57
  end
64
58
 
65
- def openai_compatible_providers=(providers)
66
- ensure_mutable!
67
- @openai_compatible_providers = normalize_openai_compatible_providers(providers)
68
- end
69
-
70
- def pricing_overrides=(value)
71
- ensure_mutable!
72
- @pricing_overrides = Pricing::Registry.normalize_price_entries(value || {}, context: "pricing_overrides")
73
- rescue ArgumentError, TypeError => e
74
- raise Error, "invalid pricing_overrides: #{e.message}"
75
- end
76
-
77
- def report_tag_breakdowns=(value)
78
- ensure_mutable!
79
- @report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
80
- end
81
-
82
- def redacted_tag_keys=(value)
83
- ensure_mutable!
84
- @redacted_tag_keys = Array(value).map(&:to_s)
85
- end
86
-
87
59
  def instrument(*names)
88
60
  ensure_mutable!
89
61
  names = names.flatten
@@ -96,83 +68,55 @@ module LlmCostTracker
96
68
  end
97
69
 
98
70
  SCALAR_ATTRIBUTES.each do |name|
99
- define_method("#{name}=") do |value|
71
+ define_method(:"#{name}=") do |value|
100
72
  ensure_mutable!
101
73
  instance_variable_set(:"@#{name}", value)
102
74
  end
103
75
  end
104
76
 
105
- ENUM_ATTRIBUTES.each do |name, (allowed, default)|
106
- define_method("#{name}=") do |value|
107
- ensure_mutable!
108
- instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default: default))
77
+ DEPRECATED_OPTIONS.each do |old_name, spec|
78
+ path = spec[:to]
79
+ replacement = path ? "config.#{path.join('.')}" : nil
80
+
81
+ define_method(:"#{old_name}=") do |value|
82
+ LlmCostTracker.deprecator.warn(deprecation_message(old_name, replacement, spec[:note], writer: true))
83
+ ensure_mutable! unless path
84
+ next instance_variable_set(:"@#{old_name}", value) unless path
85
+
86
+ value = spec[:cast].call(value) if spec[:cast]
87
+ target = path[0..-2].inject(self) { |object, step| object.public_send(step) }
88
+ target.public_send(:"#{path.last}=", value)
89
+ end
90
+
91
+ next if spec[:writer_only]
92
+
93
+ define_method(old_name) do
94
+ LlmCostTracker.deprecator.warn(deprecation_message(old_name, replacement, spec[:note], writer: false))
95
+ next instance_variable_get(:"@#{old_name}") unless path
96
+
97
+ current = path.inject(self) { |object, step| object.public_send(step) }
98
+ spec[:uncast] ? spec[:uncast].call(current) : current
109
99
  end
110
100
  end
111
101
 
112
102
  def finalize!
113
- @default_tags = deep_freeze(@default_tags || {})
114
- @pricing_overrides = deep_freeze(@pricing_overrides || {})
103
+ SECTIONS.each_key { |name| public_send(name).finalize! }
115
104
  @instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
116
- @report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
117
- @redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
118
- @openai_compatible_providers = deep_freeze(
119
- normalize_openai_compatible_providers(@openai_compatible_providers)
120
- )
121
105
  @finalized = true
122
106
  end
123
107
 
124
- def normalized_redacted_tag_keys
125
- @normalized_redacted_tag_keys ||=
126
- Array(@redacted_tag_keys).map { |key| Tags::Sanitizer.normalized_key(key) }.freeze
127
- end
128
-
129
- def static_sanitized_default_tags
130
- return nil if @default_tags.respond_to?(:call)
131
-
132
- @static_sanitized_default_tags ||= Tags::Sanitizer.call((@default_tags || {}).to_h).freeze
133
- end
134
-
135
108
  def finalized?
136
109
  @finalized
137
110
  end
138
111
 
139
112
  private
140
113
 
141
- def normalize_enum(name, value, allowed, default:)
142
- value = default if value.nil?
143
- return value if allowed.include?(value)
114
+ def deprecation_message(old_name, replacement, note, writer:)
115
+ suffix = writer ? "=" : ""
116
+ name = "config.#{old_name}#{suffix}"
117
+ return "#{name} is deprecated; use #{replacement}#{suffix}" if replacement
144
118
 
145
- raise Error, "Unknown #{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
146
- end
147
-
148
- def normalize_openai_compatible_providers(providers)
149
- (providers || {}).each_with_object({}) do |(host, provider), normalized|
150
- normalized[host.to_s.downcase] = provider.to_s
151
- end
152
- end
153
-
154
- def ensure_mutable!
155
- return unless finalized?
156
-
157
- raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
158
- end
159
-
160
- def deep_freeze(value)
161
- case value
162
- when Hash
163
- value.each do |key, nested_value|
164
- deep_freeze(key)
165
- deep_freeze(nested_value)
166
- end
167
- value.frozen? ? value : value.freeze
168
- when Array, Set
169
- value.each { |nested_value| deep_freeze(nested_value) }
170
- value.frozen? ? value : value.freeze
171
- when String
172
- value.frozen? ? value : value.freeze
173
- else
174
- value
175
- end
119
+ "#{name} is deprecated and has no effect; #{note}"
176
120
  end
177
121
  end
178
122
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/deprecation"
4
+
5
+ module LlmCostTracker
6
+ def self.deprecator
7
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "LlmCostTracker")
8
+ end
9
+ end