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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +25 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- 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
|
-
@
|
|
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
|
|
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
|
-
|
|
111
|
-
next false if @finished_ref[0]
|
|
108
|
+
next false if @finished
|
|
112
109
|
|
|
113
|
-
@
|
|
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 { @
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
include Mutability
|
|
16
|
+
|
|
17
|
+
SECTIONS = {
|
|
18
|
+
budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
|
|
13
19
|
}.freeze
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|