llm_cost_tracker 0.5.0 → 0.5.2
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 +38 -0
- data/README.md +116 -467
- data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
- data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
- data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
- data/lib/llm_cost_tracker/configuration.rb +22 -16
- data/lib/llm_cost_tracker/doctor.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
- data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
- data/lib/llm_cost_tracker/integrations/base.rb +77 -6
- data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
- data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
- data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
- data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
- data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
- data/lib/llm_cost_tracker/price_freshness.rb +3 -3
- data/lib/llm_cost_tracker/price_registry.rb +3 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
- data/lib/llm_cost_tracker/price_sync.rb +103 -111
- data/lib/llm_cost_tracker/prices.json +225 -229
- data/lib/llm_cost_tracker/pricing.rb +27 -15
- data/lib/llm_cost_tracker/report.rb +8 -1
- data/lib/llm_cost_tracker/report_data.rb +25 -9
- data/lib/llm_cost_tracker/retention.rb +30 -7
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
- data/lib/llm_cost_tracker/stream_capture.rb +7 -0
- data/lib/llm_cost_tracker/stream_collector.rb +25 -1
- data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
- data/lib/llm_cost_tracker/tracker.rb +7 -59
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- data/lib/tasks/llm_cost_tracker.rake +24 -78
- metadata +26 -15
- data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
- data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
- data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
- data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
|
@@ -32,27 +32,20 @@ module LlmCostTracker
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def lookup(provider:, model:)
|
|
35
|
-
table = prices
|
|
36
35
|
provider_name = provider.to_s
|
|
37
36
|
model_name = model.to_s
|
|
38
37
|
provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
|
|
39
38
|
normalized_model = normalize_model_name(model_name)
|
|
39
|
+
current = current_price_tables
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
fuzzy_match(provider_model, normalized_model, table)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def models
|
|
48
|
-
prices.keys
|
|
41
|
+
lookup_in_table(current.fetch(:pricing_overrides), provider_model, model_name, normalized_model) ||
|
|
42
|
+
lookup_in_table(current.fetch(:file_prices), provider_model, model_name, normalized_model) ||
|
|
43
|
+
lookup_in_table(PRICES, provider_model, model_name, normalized_model)
|
|
49
44
|
end
|
|
50
45
|
|
|
51
|
-
|
|
52
|
-
PriceRegistry.metadata
|
|
53
|
-
end
|
|
46
|
+
private
|
|
54
47
|
|
|
55
|
-
def
|
|
48
|
+
def current_price_tables
|
|
56
49
|
file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
|
|
57
50
|
overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
|
|
58
51
|
cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
|
|
@@ -64,13 +57,22 @@ module LlmCostTracker
|
|
|
64
57
|
cached = @prices_cache
|
|
65
58
|
return cached[:value] if cached && cached[:key] == cache_key
|
|
66
59
|
|
|
67
|
-
value =
|
|
60
|
+
value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
|
|
68
61
|
@prices_cache = { key: cache_key, value: value }.freeze
|
|
69
62
|
value
|
|
70
63
|
end
|
|
71
64
|
end
|
|
72
65
|
|
|
73
|
-
|
|
66
|
+
def lookup_in_table(table, provider_model, model_name, normalized_model)
|
|
67
|
+
return nil if table.empty?
|
|
68
|
+
|
|
69
|
+
table[provider_model] ||
|
|
70
|
+
table[model_name] ||
|
|
71
|
+
table[normalized_model] ||
|
|
72
|
+
unique_providerless_lookup(normalized_model, table) ||
|
|
73
|
+
fuzzy_match(provider_model, normalized_model, table) ||
|
|
74
|
+
unique_providerless_fuzzy_match(normalized_model, table)
|
|
75
|
+
end
|
|
74
76
|
|
|
75
77
|
def calculate_costs(usage, prices, pricing_mode:)
|
|
76
78
|
{
|
|
@@ -113,6 +115,11 @@ module LlmCostTracker
|
|
|
113
115
|
model.to_s.split("/").last
|
|
114
116
|
end
|
|
115
117
|
|
|
118
|
+
def unique_providerless_lookup(model, table)
|
|
119
|
+
matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
|
|
120
|
+
table[matches.first] if matches.one?
|
|
121
|
+
end
|
|
122
|
+
|
|
116
123
|
def fuzzy_match(model, normalized_model, table)
|
|
117
124
|
sorted_price_keys(table).each do |key|
|
|
118
125
|
return table[key] if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
|
|
@@ -121,6 +128,11 @@ module LlmCostTracker
|
|
|
121
128
|
nil
|
|
122
129
|
end
|
|
123
130
|
|
|
131
|
+
def unique_providerless_fuzzy_match(model, table)
|
|
132
|
+
matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
|
|
133
|
+
table[matches.first] if matches.one?
|
|
134
|
+
end
|
|
135
|
+
|
|
124
136
|
def snapshot_variant?(model, key)
|
|
125
137
|
suffix = model.delete_prefix("#{key}-")
|
|
126
138
|
return false if suffix == model
|
|
@@ -9,7 +9,14 @@ module LlmCostTracker
|
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
12
|
-
|
|
12
|
+
report_data = ReportData.build(
|
|
13
|
+
days: days,
|
|
14
|
+
now: now,
|
|
15
|
+
tag_breakdowns: tag_breakdowns,
|
|
16
|
+
breakdown_limit: ReportFormatter::TOP_LIMIT
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ReportFormatter.new(report_data).to_s
|
|
13
20
|
rescue LoadError => e
|
|
14
21
|
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
15
22
|
rescue StandardError => e
|
|
@@ -23,10 +23,11 @@ module LlmCostTracker
|
|
|
23
23
|
DEFAULT_DAYS = 30
|
|
24
24
|
TOP_LIMIT = 5
|
|
25
25
|
|
|
26
|
-
def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
26
|
+
def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
|
|
27
27
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
28
28
|
|
|
29
29
|
days = normalized_days(days)
|
|
30
|
+
breakdown_limit = normalized_limit(breakdown_limit)
|
|
30
31
|
from = now - days.days
|
|
31
32
|
scope = LlmApiCall.where(tracked_at: from..now)
|
|
32
33
|
tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
|
|
@@ -39,9 +40,9 @@ module LlmCostTracker
|
|
|
39
40
|
requests_count: scope.count,
|
|
40
41
|
average_latency_ms: average_latency_ms(scope),
|
|
41
42
|
unknown_pricing_count: scope.where(total_cost: nil).count,
|
|
42
|
-
cost_by_provider: cost_by(scope, :provider),
|
|
43
|
-
cost_by_model: cost_by(scope, :model),
|
|
44
|
-
cost_by_tags: cost_by_tags(scope, tag_breakdowns),
|
|
43
|
+
cost_by_provider: cost_by(scope, :provider, limit: breakdown_limit),
|
|
44
|
+
cost_by_model: cost_by(scope, :model, limit: breakdown_limit),
|
|
45
|
+
cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
|
|
45
46
|
top_calls: top_calls(scope)
|
|
46
47
|
)
|
|
47
48
|
end
|
|
@@ -51,18 +52,33 @@ module LlmCostTracker
|
|
|
51
52
|
days.positive? ? days : DEFAULT_DAYS
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
def self.normalized_limit(limit)
|
|
56
|
+
return nil if limit.nil?
|
|
57
|
+
|
|
58
|
+
limit = limit.to_i
|
|
59
|
+
limit.positive? ? limit : nil
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
def self.average_latency_ms(scope)
|
|
55
63
|
return nil unless LlmApiCall.latency_column?
|
|
56
64
|
|
|
57
65
|
scope.average(:latency_ms)&.to_f
|
|
58
66
|
end
|
|
59
67
|
|
|
60
|
-
def self.cost_by(scope, column)
|
|
61
|
-
scope.group(column)
|
|
68
|
+
def self.cost_by(scope, column, limit:)
|
|
69
|
+
relation = scope.group(column)
|
|
70
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
71
|
+
|
|
72
|
+
relation = relation.limit(limit) if limit
|
|
73
|
+
|
|
74
|
+
relation
|
|
75
|
+
.sum(:total_cost)
|
|
76
|
+
.transform_values(&:to_f)
|
|
77
|
+
.sort_by { |_name, cost| -cost }
|
|
62
78
|
end
|
|
63
79
|
|
|
64
|
-
def self.cost_by_tags(scope, keys)
|
|
65
|
-
keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
|
|
80
|
+
def self.cost_by_tags(scope, keys, limit:)
|
|
81
|
+
keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
|
|
66
82
|
end
|
|
67
83
|
|
|
68
84
|
def self.top_calls(scope)
|
|
@@ -73,6 +89,6 @@ module LlmCostTracker
|
|
|
73
89
|
.map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
|
|
74
90
|
end
|
|
75
91
|
|
|
76
|
-
private_class_method :normalized_days, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
|
|
92
|
+
private_class_method :normalized_days, :normalized_limit, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
|
|
77
93
|
end
|
|
78
94
|
end
|
|
@@ -6,6 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
8
|
def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
|
|
9
|
+
batch_size = normalized_batch_size(batch_size)
|
|
9
10
|
cutoff = resolve_cutoff(older_than, now)
|
|
10
11
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
11
12
|
|
|
@@ -20,14 +21,36 @@ module LlmCostTracker
|
|
|
20
21
|
|
|
21
22
|
private
|
|
22
23
|
|
|
24
|
+
def normalized_batch_size(value)
|
|
25
|
+
value = value.to_i
|
|
26
|
+
raise ArgumentError, "batch_size must be positive: #{value.inspect}" unless value.positive?
|
|
27
|
+
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def resolve_cutoff(older_than, now)
|
|
24
|
-
case older_than
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
cutoff = case older_than
|
|
33
|
+
when Time, DateTime then older_than.utc
|
|
34
|
+
when ActiveSupport::Duration then duration_cutoff(older_than, now)
|
|
35
|
+
when Integer then integer_day_cutoff(older_than, now)
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "older_than must be a Duration, Time, or Integer days: #{older_than.inspect}"
|
|
38
|
+
end
|
|
39
|
+
raise ArgumentError, "older_than cutoff must be before now: #{cutoff.inspect}" unless cutoff < now
|
|
40
|
+
|
|
41
|
+
cutoff
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def duration_cutoff(duration, now)
|
|
45
|
+
raise ArgumentError, "older_than duration must be positive: #{duration.inspect}" unless duration.to_i.positive?
|
|
46
|
+
|
|
47
|
+
now - duration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def integer_day_cutoff(days, now)
|
|
51
|
+
raise ArgumentError, "older_than days must be positive: #{days.inspect}" unless days.positive?
|
|
52
|
+
|
|
53
|
+
now - (days * 86_400)
|
|
31
54
|
end
|
|
32
55
|
end
|
|
33
56
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../logging"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
class Dispatcher
|
|
8
|
+
class << self
|
|
9
|
+
def save(event)
|
|
10
|
+
config = LlmCostTracker.configuration
|
|
11
|
+
case config.storage_backend
|
|
12
|
+
when :log then log_event(event, config)
|
|
13
|
+
when :active_record then active_record_save(event)
|
|
14
|
+
when :custom then custom_save(event, config)
|
|
15
|
+
end
|
|
16
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
17
|
+
raise
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
handle_error(e)
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def log_event(event, config)
|
|
26
|
+
message = "#{event.provider}/#{event.model} " \
|
|
27
|
+
"tokens=#{event.total_tokens} " \
|
|
28
|
+
"cost=#{log_cost_label(event)}"
|
|
29
|
+
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
30
|
+
message += " stream=#{event.stream}" if event.stream
|
|
31
|
+
message += " source=#{event.usage_source}" if event.usage_source
|
|
32
|
+
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
33
|
+
|
|
34
|
+
Logging.log(config.log_level, message)
|
|
35
|
+
event
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
|
|
39
|
+
|
|
40
|
+
def active_record_save(event)
|
|
41
|
+
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
42
|
+
require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
43
|
+
|
|
44
|
+
ActiveRecordStore.save(event)
|
|
45
|
+
event
|
|
46
|
+
rescue LoadError => e
|
|
47
|
+
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def custom_save(event, config)
|
|
51
|
+
result = config.custom_storage&.call(event)
|
|
52
|
+
result == false ? false : event
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_error(error)
|
|
56
|
+
case LlmCostTracker.configuration.storage_error_behavior
|
|
57
|
+
when :ignore
|
|
58
|
+
nil
|
|
59
|
+
when :warn
|
|
60
|
+
Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
|
|
61
|
+
when :raise
|
|
62
|
+
raise StorageError, error
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "monitor"
|
|
4
5
|
|
|
6
|
+
require_relative "stream_capture"
|
|
5
7
|
require_relative "value_helpers"
|
|
6
8
|
|
|
7
9
|
module LlmCostTracker
|
|
@@ -16,6 +18,8 @@ module LlmCostTracker
|
|
|
16
18
|
@pricing_mode = pricing_mode
|
|
17
19
|
@metadata = ValueHelpers.deep_dup(metadata || {})
|
|
18
20
|
@events = []
|
|
21
|
+
@captured_bytes = 0
|
|
22
|
+
@overflowed = false
|
|
19
23
|
@explicit_usage = nil
|
|
20
24
|
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
21
25
|
@finished = false
|
|
@@ -45,7 +49,7 @@ module LlmCostTracker
|
|
|
45
49
|
def event(data, type: nil)
|
|
46
50
|
@monitor.synchronize do
|
|
47
51
|
ensure_open!
|
|
48
|
-
|
|
52
|
+
capture_event(data, type: type) unless data.nil?
|
|
49
53
|
end
|
|
50
54
|
self
|
|
51
55
|
end
|
|
@@ -71,6 +75,7 @@ module LlmCostTracker
|
|
|
71
75
|
@finished = true
|
|
72
76
|
{
|
|
73
77
|
events: @events.dup,
|
|
78
|
+
overflowed: @overflowed,
|
|
74
79
|
explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
|
|
75
80
|
model: @model,
|
|
76
81
|
latency_ms: @latency_ms,
|
|
@@ -105,6 +110,7 @@ module LlmCostTracker
|
|
|
105
110
|
|
|
106
111
|
def build_parsed_usage(snapshot)
|
|
107
112
|
return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
|
|
113
|
+
return build_unknown_usage(snapshot) if snapshot[:overflowed]
|
|
108
114
|
|
|
109
115
|
parsed = Parsers::Registry.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
|
|
110
116
|
return finalize(parsed, snapshot) if parsed
|
|
@@ -157,6 +163,24 @@ module LlmCostTracker
|
|
|
157
163
|
)
|
|
158
164
|
end
|
|
159
165
|
|
|
166
|
+
def capture_event(data, type:)
|
|
167
|
+
copied = ValueHelpers.deep_dup(data)
|
|
168
|
+
size = event_bytes(copied, type)
|
|
169
|
+
if @captured_bytes + size <= StreamCapture::LIMIT_BYTES
|
|
170
|
+
@events << { event: type, data: copied }
|
|
171
|
+
@captured_bytes += size
|
|
172
|
+
else
|
|
173
|
+
@overflowed = true
|
|
174
|
+
@events.clear
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def event_bytes(data, type)
|
|
179
|
+
JSON.generate(event: type, data: data).bytesize
|
|
180
|
+
rescue JSON::GeneratorError, TypeError
|
|
181
|
+
type.to_s.bytesize + data.to_s.bytesize
|
|
182
|
+
end
|
|
183
|
+
|
|
160
184
|
def error_metadata(errored) = errored ? { stream_errored: true } : {}
|
|
161
185
|
|
|
162
186
|
def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module TagSanitizer
|
|
7
|
+
REDACTED_VALUE = "[REDACTED]"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(tags, config: LlmCostTracker.configuration)
|
|
11
|
+
tags = (tags || {}).to_h
|
|
12
|
+
tags.first(max_tag_count(config)).each_with_object({}) do |(key, value), sanitized|
|
|
13
|
+
sanitized[key] = sanitized_value(key, value, config)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def sanitized_value(key, value, config)
|
|
20
|
+
return REDACTED_VALUE if redacted_key?(key, config)
|
|
21
|
+
|
|
22
|
+
string = value_string(value)
|
|
23
|
+
return value if string.bytesize <= max_tag_value_bytesize(config)
|
|
24
|
+
|
|
25
|
+
truncate_bytes(string, max_tag_value_bytesize(config))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def redacted_key?(key, config)
|
|
29
|
+
normalized = normalized_key(key)
|
|
30
|
+
redacted_keys(config).any? do |candidate|
|
|
31
|
+
redacted_key_component?(normalized, candidate)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def redacted_keys(config)
|
|
36
|
+
Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalized_key(key)
|
|
40
|
+
key.to_s
|
|
41
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
42
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
43
|
+
.downcase
|
|
44
|
+
.gsub(/[^a-z0-9]+/, "_")
|
|
45
|
+
.gsub(/_+/, "_")
|
|
46
|
+
.delete_prefix("_")
|
|
47
|
+
.delete_suffix("_")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def redacted_key_component?(key, candidate)
|
|
51
|
+
key == candidate ||
|
|
52
|
+
key.start_with?("#{candidate}_") ||
|
|
53
|
+
key.end_with?("_#{candidate}") ||
|
|
54
|
+
key.include?("_#{candidate}_")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def value_string(value)
|
|
58
|
+
case value
|
|
59
|
+
when Hash, Array
|
|
60
|
+
JSON.generate(value)
|
|
61
|
+
else
|
|
62
|
+
value.to_s
|
|
63
|
+
end
|
|
64
|
+
rescue JSON::GeneratorError, TypeError
|
|
65
|
+
value.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def truncate_bytes(string, limit)
|
|
69
|
+
string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def max_tag_count(config)
|
|
73
|
+
[config.max_tag_count.to_i, 0].max
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def max_tag_value_bytesize(config)
|
|
77
|
+
[config.max_tag_value_bytesize.to_i, 0].max
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "storage/dispatcher"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
class Tracker
|
|
7
7
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
8
8
|
|
|
9
|
-
USAGE_SOURCES = %i[response stream_final sdk_response manual unknown].freeze
|
|
9
|
+
USAGE_SOURCES = %i[response stream_final sdk_response ruby_llm manual unknown].freeze
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def enforce_budget!
|
|
@@ -39,7 +39,7 @@ module LlmCostTracker
|
|
|
39
39
|
|
|
40
40
|
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
41
41
|
|
|
42
|
-
stored =
|
|
42
|
+
stored = Storage::Dispatcher.save(event)
|
|
43
43
|
Budget.check!(event) unless stored == false
|
|
44
44
|
|
|
45
45
|
event
|
|
@@ -84,7 +84,7 @@ module LlmCostTracker
|
|
|
84
84
|
hidden_output_tokens: usage[:hidden_output_tokens],
|
|
85
85
|
pricing_mode: usage[:pricing_mode],
|
|
86
86
|
cost: cost_data,
|
|
87
|
-
tags:
|
|
87
|
+
tags: sanitized_tags(metadata).freeze,
|
|
88
88
|
latency_ms: normalized_latency_ms(latency_ms),
|
|
89
89
|
stream: stream ? true : false,
|
|
90
90
|
usage_source: normalized_usage_source(usage_source),
|
|
@@ -93,64 +93,12 @@ module LlmCostTracker
|
|
|
93
93
|
)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
def
|
|
97
|
-
config = LlmCostTracker.configuration
|
|
98
|
-
case config.storage_backend
|
|
99
|
-
when :log then log_event(event, config)
|
|
100
|
-
when :active_record then active_record_save(event)
|
|
101
|
-
when :custom then custom_save(event, config)
|
|
102
|
-
end
|
|
103
|
-
rescue BudgetExceededError, UnknownPricingError
|
|
104
|
-
raise
|
|
105
|
-
rescue StandardError => e
|
|
106
|
-
handle_storage_error(e)
|
|
107
|
-
false
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def log_event(event, config)
|
|
111
|
-
message = "#{event.provider}/#{event.model} " \
|
|
112
|
-
"tokens=#{event.total_tokens} " \
|
|
113
|
-
"cost=#{log_cost_label(event)}"
|
|
114
|
-
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
115
|
-
message += " stream=#{event.stream}" if event.stream
|
|
116
|
-
message += " source=#{event.usage_source}" if event.usage_source
|
|
117
|
-
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
118
|
-
|
|
119
|
-
Logging.log(config.log_level, message)
|
|
120
|
-
event
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
|
|
124
|
-
|
|
125
|
-
def active_record_save(event)
|
|
126
|
-
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
127
|
-
require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
128
|
-
|
|
129
|
-
Storage::ActiveRecordStore.save(event)
|
|
130
|
-
event
|
|
131
|
-
rescue LoadError => e
|
|
132
|
-
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def custom_save(event, config)
|
|
136
|
-
result = config.custom_storage&.call(event)
|
|
137
|
-
result == false ? false : event
|
|
138
|
-
end
|
|
96
|
+
def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
|
|
139
97
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
when :ignore
|
|
143
|
-
nil
|
|
144
|
-
when :warn
|
|
145
|
-
Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
|
|
146
|
-
when :raise
|
|
147
|
-
storage_error = StorageError.new(error)
|
|
148
|
-
raise storage_error
|
|
149
|
-
end
|
|
98
|
+
def sanitized_tags(metadata)
|
|
99
|
+
LlmCostTracker::TagSanitizer.call(LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)))
|
|
150
100
|
end
|
|
151
101
|
|
|
152
|
-
def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
|
|
153
|
-
|
|
154
102
|
def normalized_usage_source(value)
|
|
155
103
|
return nil if value.nil?
|
|
156
104
|
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -30,6 +30,7 @@ require_relative "llm_cost_tracker/budget"
|
|
|
30
30
|
require_relative "llm_cost_tracker/unknown_pricing"
|
|
31
31
|
require_relative "llm_cost_tracker/event_metadata"
|
|
32
32
|
require_relative "llm_cost_tracker/tag_context"
|
|
33
|
+
require_relative "llm_cost_tracker/tag_sanitizer"
|
|
33
34
|
require_relative "llm_cost_tracker/tags_column"
|
|
34
35
|
require_relative "llm_cost_tracker/tag_key"
|
|
35
36
|
require_relative "llm_cost_tracker/tag_query"
|