llm_cost_tracker 0.13.0 → 0.14.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -16
  3. data/README.md +16 -28
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
  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/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
  8. data/app/models/llm_cost_tracker/call.rb +9 -3
  9. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  10. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  11. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  12. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  13. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  14. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  15. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  16. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  17. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  18. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  19. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  20. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  21. data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
  22. data/lib/llm_cost_tracker/budget.rb +120 -32
  23. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  24. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  25. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  26. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  27. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  28. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  29. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  30. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  31. data/lib/llm_cost_tracker/configuration.rb +69 -125
  32. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  33. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  34. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  35. data/lib/llm_cost_tracker/doctor.rb +4 -4
  36. data/lib/llm_cost_tracker/engine.rb +4 -0
  37. data/lib/llm_cost_tracker/errors.rb +12 -3
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  40. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  41. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  42. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  43. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  47. data/lib/llm_cost_tracker/ingestion/batch.rb +3 -1
  48. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  49. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  50. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
  51. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  52. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
  53. data/lib/llm_cost_tracker/ledger/store.rb +9 -2
  54. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  55. data/lib/llm_cost_tracker/logging.rb +3 -5
  56. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
  57. data/lib/llm_cost_tracker/prices.json +1291 -313
  58. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  59. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  60. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  61. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  62. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  63. data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
  64. data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
  65. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  66. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  67. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  68. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
  69. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
  70. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
  71. data/lib/llm_cost_tracker/railtie.rb +3 -7
  72. data/lib/llm_cost_tracker/report/data.rb +2 -2
  73. data/lib/llm_cost_tracker/retention.rb +22 -10
  74. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  75. data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
  76. data/lib/llm_cost_tracker/tracker.rb +14 -23
  77. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  78. data/lib/llm_cost_tracker/version.rb +1 -1
  79. data/lib/llm_cost_tracker.rb +6 -1
  80. data/lib/tasks/llm_cost_tracker.rake +23 -10
  81. metadata +22 -9
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ledger/schema/adapter"
4
+ require_relative "../ledger/tags/encoding"
5
+
6
+ module LlmCostTracker
7
+ module Budget
8
+ module PerTag
9
+ COST_COLUMN = "total_cost"
10
+ TIME_COLUMN = "tracked_at"
11
+ WINDOW_STARTS = { daily: :beginning_of_day, weekly: :beginning_of_week, monthly: :beginning_of_month }.freeze
12
+ WINDOW_NEXTS = { daily: :next_day, weekly: :next_week, monthly: :next_month }.freeze
13
+ SLOW_READ_SECONDS = 0.1
14
+ DEFAULT_BACKFILL_BATCH = 5_000
15
+
16
+ Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
17
+
18
+ class << self
19
+ def configured
20
+ LlmCostTracker.configuration.budgets.per_tag
21
+ end
22
+
23
+ def active?
24
+ return false if configured.empty?
25
+ return true if columns?
26
+
27
+ warn_missing_columns
28
+ false
29
+ end
30
+
31
+ def blocking?
32
+ active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
33
+ end
34
+
35
+ def rules_for(tags, blocking_only: false)
36
+ return [] unless active?
37
+
38
+ normalized = (tags || {}).to_h.transform_keys(&:to_s)
39
+ configured.filter_map do |key, entry|
40
+ raw = normalized[key]
41
+ next if raw.nil?
42
+ next if blocking_only && behavior_for(entry) != :block_requests
43
+
44
+ Rule.new(
45
+ key: key,
46
+ value: Ledger::Tags::Encoding.encode(raw),
47
+ windows: entry[:windows],
48
+ behavior: behavior_for(entry),
49
+ on_exceeded: on_exceeded_for(entry)
50
+ )
51
+ end
52
+ end
53
+
54
+ def rules_for_events(events)
55
+ events.each_with_object({}) do |event, grouped|
56
+ rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
57
+ end
58
+ end
59
+
60
+ def spend(key, value, window, time:)
61
+ spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
62
+ end
63
+
64
+ def spend_by_value(key, values, window, bucket)
65
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ totals = LlmCostTracker::CallTag
67
+ .where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
68
+ .group(:value).sum(COST_COLUMN)
69
+ warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
70
+ totals
71
+ end
72
+
73
+ def window_start(window, time)
74
+ time.to_time.utc.public_send(WINDOW_STARTS.fetch(window))
75
+ end
76
+
77
+ def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
78
+ return 0 unless columns?
79
+
80
+ filled = 0
81
+ loop do
82
+ copied = copy_next_batch(batch_size)
83
+ break if copied.zero?
84
+
85
+ filled += copied
86
+ end
87
+ filled
88
+ end
89
+
90
+ def columns?
91
+ (LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
92
+ end
93
+
94
+ private
95
+
96
+ def window_range(window, bucket)
97
+ bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
98
+ end
99
+
100
+ def copy_next_batch(batch_size)
101
+ ids = LlmCostTracker::CallTag.where(TIME_COLUMN => nil).limit(batch_size).pluck(:id)
102
+ return 0 if ids.empty?
103
+
104
+ LlmCostTracker::CallTag.connection.update(copy_sql(ids))
105
+ end
106
+
107
+ def copy_sql(ids)
108
+ tags = LlmCostTracker::CallTag.quoted_table_name
109
+ calls = LlmCostTracker::Call.quoted_table_name
110
+ list = ids.join(",")
111
+ return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.connection)
112
+ UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
113
+ SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
114
+ WHERE t.id IN (#{list})
115
+ SQL
116
+
117
+ <<~SQL.squish
118
+ UPDATE #{tags} AS t
119
+ SET #{COST_COLUMN} = c.#{COST_COLUMN}, #{TIME_COLUMN} = c.#{TIME_COLUMN}
120
+ FROM #{calls} AS c
121
+ WHERE c.id = t.llm_cost_tracker_call_id AND t.id IN (#{list})
122
+ SQL
123
+ end
124
+
125
+ def behavior_for(entry)
126
+ entry[:behavior] || LlmCostTracker.configuration.budgets.exceeded_behavior
127
+ end
128
+
129
+ def on_exceeded_for(entry)
130
+ entry[:on_exceeded] || LlmCostTracker.configuration.budgets.on_exceeded
131
+ end
132
+
133
+ def warn_slow_read(key, window, seconds)
134
+ return if seconds < SLOW_READ_SECONDS
135
+
136
+ @slow_keys ||= Set.new
137
+ return unless @slow_keys.add?(key)
138
+
139
+ Logging.warn(
140
+ "config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. " \
141
+ "A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
142
+ "an index effectively. Budget high-cardinality tags such as a tenant or user id."
143
+ )
144
+ end
145
+
146
+ def warn_missing_columns
147
+ return if @missing_columns_warned
148
+
149
+ @missing_columns_warned = true
150
+ Logging.warn(
151
+ "config.budgets.per_tag is set but llm_cost_tracker_call_tags is missing " \
152
+ "#{COST_COLUMN} / #{TIME_COLUMN}; per-tag budgets are not enforced. Run the " \
153
+ "upgrade_per_tag_budgets generator and migrate, or clear config.budgets.per_tag."
154
+ )
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -4,42 +4,94 @@ require "bigdecimal"
4
4
 
5
5
  require_relative "ledger"
6
6
  require_relative "pricing/estimator"
7
+ require_relative "budget/per_tag"
8
+ require_relative "tags/context"
7
9
 
8
10
  module LlmCostTracker
9
11
  module Budget
10
12
  BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
11
13
 
12
14
  class << self
13
- def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
15
+ def enforce!(provider: nil, model: nil, request: nil, estimate: nil, tags: nil, force: false)
14
16
  config = LlmCostTracker.configuration
15
17
  return unless config.enabled
16
- return unless force || config.budget_exceeded_behavior == :block_requests
18
+
19
+ globally = force || config.budgets.exceeded_behavior == :block_requests
20
+ per_tag = force || PerTag.blocking?
21
+ return unless globally || per_tag
17
22
 
18
23
  estimate ||= estimate_cost(provider: provider, model: model, request: request)
19
- raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
20
-
21
- check_windowed({ monthly: config.monthly_budget, daily: config.daily_budget }.compact,
22
- time: Time.now.utc,
23
- estimate: estimate) do |budget_type, total, budget|
24
- raise BudgetExceededError.new(**budget_payload(
25
- budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
26
- ))
24
+ now = Time.now.utc
25
+ enforce_globally(config, estimate: estimate, time: now) if globally
26
+ return unless per_tag
27
+
28
+ check_per_tag(tags || Tags::Context.tags,
29
+ time: now,
30
+ estimate: estimate,
31
+ blocking_only: !force) do |rule, window, total, limit|
32
+ raise_pre_send(budget_type: window, total: total, budget: limit, scope: scope_for(rule))
27
33
  end
28
34
  end
29
35
 
30
- def check!(event)
36
+ def check!(event, behavior_override: nil)
31
37
  config = LlmCostTracker.configuration
32
38
  return unless event.total_cost
33
39
 
34
- check_per_call_budget(event, config)
35
- check_windowed({ daily: config.daily_budget, monthly: config.monthly_budget }.compact,
40
+ check_per_call_budget(event, config, behavior_override)
41
+ check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
36
42
  time: event.tracked_at) do |budget_type, total, budget|
37
- handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event)
43
+ handle_exceeded(budget_type: budget_type,
44
+ total: total,
45
+ budget: budget,
46
+ previous_total: total - event.total_cost,
47
+ last_event: event,
48
+ behavior: behavior_override)
49
+ end
50
+ end
51
+
52
+ def check_persisted!(events, behavior_override: nil)
53
+ by_rule = PerTag.rules_for_events(events.select(&:total_cost))
54
+ by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
55
+ window_buckets(by_rule).each do |(key, window, bucket), scored|
56
+ totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket)
57
+ scored.each do |rule, recorded|
58
+ total = totals.fetch(rule.value, 0).to_d
59
+ limit = rule.windows.fetch(window)
60
+ next if total < limit
61
+
62
+ handle_exceeded(
63
+ budget_type: window,
64
+ total: total,
65
+ budget: limit,
66
+ previous_total: total - recorded.sum(&:total_cost),
67
+ last_event: recorded.last,
68
+ scope: scope_for(rule),
69
+ behavior: behavior_override || rule.behavior,
70
+ on_exceeded: rule.on_exceeded
71
+ )
72
+ end
38
73
  end
39
74
  end
40
75
 
76
+ def notify_persisted_safely!(events)
77
+ check_persisted!(events, behavior_override: :notify)
78
+ rescue StandardError => e
79
+ Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
80
+ end
81
+
41
82
  private
42
83
 
84
+ def window_buckets(by_rule)
85
+ by_rule.each_with_object({}) do |(rule, events), grouped|
86
+ rule.windows.each_key do |window|
87
+ events.group_by { |event| PerTag.window_start(window, event.tracked_at) }
88
+ .each do |bucket, bucket_events|
89
+ (grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events
90
+ end
91
+ end
92
+ end
93
+ end
94
+
43
95
  def estimate_cost(provider:, model:, request:)
44
96
  return BigDecimal("0") unless provider && model && request
45
97
 
@@ -54,14 +106,48 @@ estimate: estimate) do |budget_type, total, budget|
54
106
  ))
55
107
  end
56
108
 
57
- def check_per_call_budget(event, config)
58
- budget = config.per_call_budget
109
+ def check_per_call_budget(event, config, behavior_override)
110
+ budget = config.budgets.per_call
59
111
  return unless budget
60
112
 
61
113
  total = event.total_cost
62
114
  return unless total >= budget
63
115
 
64
- handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
116
+ handle_exceeded(budget_type: :per_call,
117
+ total: total,
118
+ budget: budget,
119
+ previous_total: nil,
120
+ last_event: event,
121
+ behavior: behavior_override)
122
+ end
123
+
124
+ def enforce_globally(config, estimate:, time:)
125
+ raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
126
+
127
+ check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
128
+ time: time,
129
+ estimate: estimate) do |budget_type, total, budget|
130
+ raise_pre_send(budget_type: budget_type, total: total, budget: budget)
131
+ end
132
+ end
133
+
134
+ def raise_pre_send(budget_type:, total:, budget:, scope: nil)
135
+ raise BudgetExceededError.new(**budget_payload(
136
+ budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send, scope: scope
137
+ ))
138
+ end
139
+
140
+ def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
141
+ PerTag.rules_for(tags, blocking_only: blocking_only).each do |rule|
142
+ rule.windows.each do |window, limit|
143
+ total = PerTag.spend(rule.key, rule.value, window, time: time) + estimate
144
+ yield(rule, window, total, limit) if total >= limit
145
+ end
146
+ end
147
+ end
148
+
149
+ def scope_for(rule)
150
+ { key: rule.key, value: rule.value }
65
151
  end
66
152
 
67
153
  def check_windowed(budgets, time:, estimate: BigDecimal("0"))
@@ -82,38 +168,40 @@ estimate: estimate) do |budget_type, total, budget|
82
168
  period_for.transform_values { |period| period_totals.fetch(period) }
83
169
  end
84
170
 
85
- def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
171
+ def handle_exceeded(budget_type:,
172
+ total:,
173
+ budget:,
174
+ previous_total:,
175
+ last_event: nil,
176
+ scope: nil,
177
+ behavior: nil,
178
+ on_exceeded: nil)
86
179
  config = LlmCostTracker.configuration
180
+ behavior ||= config.budgets.exceeded_behavior
181
+ on_exceeded ||= config.budgets.on_exceeded
87
182
  payload = budget_payload(
88
183
  budget_type: budget_type,
89
184
  total: total,
90
185
  budget: budget,
91
186
  last_event: last_event,
92
- stage: :post_spend
187
+ stage: :post_spend,
188
+ scope: scope
93
189
  )
94
190
 
95
- if notify_exceeded?(config, budget_type: budget_type, total: total, budget: budget, last_event: last_event)
96
- config.on_budget_exceeded&.call(payload)
97
- end
98
- raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
191
+ on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
192
+ raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
99
193
  end
100
194
 
101
- def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
195
+ def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
102
196
  {
103
197
  budget_type: budget_type,
104
198
  total: total,
105
199
  budget: budget,
106
200
  last_event: last_event,
107
- stage: stage
201
+ stage: stage,
202
+ scope: scope
108
203
  }
109
204
  end
110
-
111
- def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
112
- return false unless config.on_budget_exceeded
113
- return true if !last_event&.total_cost || budget_type == :per_call
114
-
115
- total - last_event.total_cost < budget
116
- end
117
205
  end
118
206
  end
119
207
  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