llm_cost_tracker 0.1.3 → 0.2.0.alpha1
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 +64 -81
- data/PLAN_0.2.md +488 -0
- data/README.md +141 -316
- data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
- data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
- data/app/services/llm_cost_tracker/pagination.rb +59 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
- data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
- data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
- data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
- data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
- data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
- data/config/routes.rb +10 -0
- data/lib/llm_cost_tracker/budget.rb +16 -38
- data/lib/llm_cost_tracker/configuration.rb +3 -1
- data/lib/llm_cost_tracker/cost.rb +1 -3
- data/lib/llm_cost_tracker/engine.rb +13 -0
- data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
- data/lib/llm_cost_tracker/errors.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -3
- data/lib/llm_cost_tracker/event_metadata.rb +9 -18
- data/lib/llm_cost_tracker/llm_api_call.rb +43 -9
- data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
- data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
- data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
- data/lib/llm_cost_tracker/parsers/base.rb +3 -8
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
- data/lib/llm_cost_tracker/period_grouping.rb +68 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -30
- data/lib/llm_cost_tracker/pricing.rb +10 -19
- data/lib/llm_cost_tracker/report.rb +4 -4
- data/lib/llm_cost_tracker/report_data.rb +23 -29
- data/lib/llm_cost_tracker/report_formatter.rb +11 -3
- data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -8
- data/lib/llm_cost_tracker/tag_key.rb +16 -0
- data/lib/llm_cost_tracker/tracker.rb +35 -1
- data/lib/llm_cost_tracker/unknown_pricing.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +3 -6
- data/llm_cost_tracker.gemspec +13 -9
- metadata +92 -21
- data/.rubocop.yml +0 -44
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
- data/lib/llm_cost_tracker/storage/backends.rb +0 -26
- data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
- data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
- data/lib/llm_cost_tracker/value_object.rb +0 -45
|
@@ -11,54 +11,50 @@ module LlmCostTracker
|
|
|
11
11
|
EMPTY_PRICES = {}.freeze
|
|
12
12
|
PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
|
|
13
13
|
METADATA_KEYS = %w[_source _updated _notes].freeze
|
|
14
|
-
FILE_PRICES_MUTEX = Mutex.new
|
|
15
|
-
NORMALIZE_PRICE_ENTRY = lambda do |price|
|
|
16
|
-
(price || {}).each_with_object({}) do |(key, value), normalized|
|
|
17
|
-
key = key.to_s
|
|
18
|
-
normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
NORMALIZE_PRICE_TABLE = lambda do |table|
|
|
22
|
-
(table || {}).each_with_object({}) do |(model, price), normalized|
|
|
23
|
-
normalized[model.to_s] = NORMALIZE_PRICE_ENTRY.call(price)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
RAW_REGISTRY = JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
|
|
27
|
-
PRICE_METADATA = RAW_REGISTRY.fetch("metadata", {}).freeze
|
|
28
|
-
BUILTIN_PRICES = NORMALIZE_PRICE_TABLE.call(RAW_REGISTRY.fetch("models", {})).freeze
|
|
29
|
-
|
|
30
|
-
private_constant :FILE_PRICES_MUTEX
|
|
31
14
|
|
|
32
15
|
class << self
|
|
33
16
|
def builtin_prices
|
|
34
|
-
|
|
17
|
+
@builtin_prices ||= normalize_price_table(raw_registry.fetch("models", {})).freeze
|
|
35
18
|
end
|
|
36
19
|
|
|
37
20
|
def metadata
|
|
38
|
-
|
|
21
|
+
@metadata ||= raw_registry.fetch("metadata", {}).freeze
|
|
39
22
|
end
|
|
40
23
|
|
|
41
24
|
def normalize_price_table(table)
|
|
42
|
-
|
|
25
|
+
(table || {}).each_with_object({}) do |(model, price), normalized|
|
|
26
|
+
normalized[model.to_s] = normalize_price_entry(price)
|
|
27
|
+
end
|
|
43
28
|
end
|
|
44
29
|
|
|
45
30
|
def file_prices(path)
|
|
46
31
|
return EMPTY_PRICES unless path
|
|
47
32
|
|
|
48
33
|
path = path.to_s
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
cache_key = [path, File.mtime(path).to_f]
|
|
35
|
+
cached = @file_prices_cache
|
|
36
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
52
37
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
38
|
+
value = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
|
|
39
|
+
@file_prices_cache = { key: cache_key, value: value }.freeze
|
|
40
|
+
value
|
|
56
41
|
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
|
|
57
42
|
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
58
43
|
end
|
|
59
44
|
|
|
60
45
|
private
|
|
61
46
|
|
|
47
|
+
def raw_registry
|
|
48
|
+
@raw_registry ||= JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_price_entry(price)
|
|
52
|
+
(price || {}).each_with_object({}) do |(key, value), normalized|
|
|
53
|
+
key = key.to_s
|
|
54
|
+
normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
62
58
|
def normalize_file_prices(table, path:)
|
|
63
59
|
(table || {}).each_with_object({}) do |(model, price), normalized|
|
|
64
60
|
warn_unknown_keys(model, price, path)
|
|
@@ -66,10 +62,6 @@ module LlmCostTracker
|
|
|
66
62
|
end
|
|
67
63
|
end
|
|
68
64
|
|
|
69
|
-
def normalize_price_entry(price)
|
|
70
|
-
NORMALIZE_PRICE_ENTRY.call(price)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
65
|
def warn_unknown_keys(model, price, path)
|
|
74
66
|
unknown_keys = price.keys.map(&:to_s) - PRICE_KEYS - METADATA_KEYS
|
|
75
67
|
return if unknown_keys.empty?
|
|
@@ -4,11 +4,6 @@ module LlmCostTracker
|
|
|
4
4
|
# Calculates costs from price entries expressed in USD per 1M tokens.
|
|
5
5
|
module Pricing
|
|
6
6
|
PRICES = PriceRegistry.builtin_prices
|
|
7
|
-
PRICES_MUTEX = Mutex.new
|
|
8
|
-
SORTED_PRICE_KEYS_MUTEX = Mutex.new
|
|
9
|
-
|
|
10
|
-
private_constant :PRICES_MUTEX
|
|
11
|
-
private_constant :SORTED_PRICE_KEYS_MUTEX
|
|
12
7
|
|
|
13
8
|
class << self
|
|
14
9
|
# Estimate model cost from token counts.
|
|
@@ -61,14 +56,12 @@ module LlmCostTracker
|
|
|
61
56
|
overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
|
|
62
57
|
cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
PRICES_MUTEX.synchronize do
|
|
67
|
-
return @prices if @prices_cache_key == cache_key
|
|
59
|
+
cached = @prices_cache
|
|
60
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
value = PRICES.merge(file_prices).merge(overrides).freeze
|
|
63
|
+
@prices_cache = { key: cache_key, value: value }.freeze
|
|
64
|
+
value
|
|
72
65
|
end
|
|
73
66
|
|
|
74
67
|
private
|
|
@@ -120,14 +113,12 @@ module LlmCostTracker
|
|
|
120
113
|
end
|
|
121
114
|
|
|
122
115
|
def sorted_price_keys(table)
|
|
123
|
-
|
|
116
|
+
cached = @sorted_price_keys_cache
|
|
117
|
+
return cached[:keys] if cached && cached[:table].equal?(table)
|
|
124
118
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
@sorted_price_keys_table = table
|
|
129
|
-
@sorted_price_keys = table.keys.sort_by { |key| -key.length }
|
|
130
|
-
end
|
|
119
|
+
keys = table.keys.sort_by { |key| -key.length }
|
|
120
|
+
@sorted_price_keys_cache = { table: table, keys: keys }.freeze
|
|
121
|
+
keys
|
|
131
122
|
end
|
|
132
123
|
end
|
|
133
124
|
end
|
|
@@ -13,16 +13,16 @@ module LlmCostTracker
|
|
|
13
13
|
# @param days [Integer] Number of trailing days to include.
|
|
14
14
|
# @param now [Time] Report end time.
|
|
15
15
|
# @return [String]
|
|
16
|
-
def generate(days: DEFAULT_DAYS, now: Time.now.utc)
|
|
17
|
-
ReportFormatter.new(data(days: days, now: now)).to_s
|
|
16
|
+
def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
17
|
+
ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
|
|
18
18
|
rescue LoadError => e
|
|
19
19
|
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
20
20
|
rescue StandardError => e
|
|
21
21
|
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def data(days: DEFAULT_DAYS, now: Time.now.utc)
|
|
25
|
-
ReportData.build(days: days, now: now)
|
|
24
|
+
def data(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
25
|
+
ReportData.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "active_support/core_ext/integer/time"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
|
-
TopCall =
|
|
6
|
+
TopCall = Data.define(:provider, :model, :total_cost)
|
|
7
7
|
|
|
8
|
-
ReportData =
|
|
8
|
+
ReportData = Data.define(
|
|
9
9
|
:days,
|
|
10
10
|
:from_time,
|
|
11
11
|
:to_time,
|
|
@@ -15,23 +15,25 @@ module LlmCostTracker
|
|
|
15
15
|
:unknown_pricing_count,
|
|
16
16
|
:cost_by_provider,
|
|
17
17
|
:cost_by_model,
|
|
18
|
-
:
|
|
18
|
+
:cost_by_tags,
|
|
19
19
|
:top_calls
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
ReportData
|
|
23
|
-
|
|
22
|
+
class ReportData
|
|
23
|
+
DEFAULT_DAYS = 30
|
|
24
|
+
TOP_LIMIT = 5
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
def build(days: ReportData::DEFAULT_DAYS, now: Time.now.utc)
|
|
26
|
+
def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
27
27
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
28
28
|
|
|
29
29
|
days = normalized_days(days)
|
|
30
|
-
|
|
30
|
+
from = now - days.days
|
|
31
|
+
scope = LlmApiCall.where(tracked_at: from..now)
|
|
32
|
+
tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
|
|
31
33
|
|
|
32
34
|
new(
|
|
33
35
|
days: days,
|
|
34
|
-
from_time:
|
|
36
|
+
from_time: from,
|
|
35
37
|
to_time: now,
|
|
36
38
|
total_cost: scope.sum(:total_cost).to_f,
|
|
37
39
|
requests_count: scope.count,
|
|
@@ -39,46 +41,38 @@ module LlmCostTracker
|
|
|
39
41
|
unknown_pricing_count: scope.where(total_cost: nil).count,
|
|
40
42
|
cost_by_provider: cost_by(scope, :provider),
|
|
41
43
|
cost_by_model: cost_by(scope, :model),
|
|
42
|
-
|
|
44
|
+
cost_by_tags: cost_by_tags(scope, tag_breakdowns),
|
|
43
45
|
top_calls: top_calls(scope)
|
|
44
46
|
)
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def normalized_days(days)
|
|
49
|
+
def self.normalized_days(days)
|
|
50
50
|
days = days.to_i
|
|
51
|
-
days.positive? ? days :
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def from_time(days, now)
|
|
55
|
-
now - (days * 86_400)
|
|
51
|
+
days.positive? ? days : DEFAULT_DAYS
|
|
56
52
|
end
|
|
57
53
|
|
|
58
|
-
def average_latency_ms(scope)
|
|
54
|
+
def self.average_latency_ms(scope)
|
|
59
55
|
return nil unless LlmApiCall.latency_column?
|
|
60
56
|
|
|
61
57
|
scope.average(:latency_ms)&.to_f
|
|
62
58
|
end
|
|
63
59
|
|
|
64
|
-
def cost_by(scope, column)
|
|
60
|
+
def self.cost_by(scope, column)
|
|
65
61
|
scope.group(column).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
|
|
66
62
|
end
|
|
67
63
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
scope.select(:id, :tags, :total_cost).find_each do |call|
|
|
71
|
-
costs[call.feature || "(untagged)"] += call.total_cost.to_f
|
|
72
|
-
end
|
|
73
|
-
costs.sort_by { |_feature, cost| -cost }
|
|
64
|
+
def self.cost_by_tags(scope, keys)
|
|
65
|
+
keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
|
|
74
66
|
end
|
|
75
67
|
|
|
76
|
-
def top_calls(scope)
|
|
68
|
+
def self.top_calls(scope)
|
|
77
69
|
scope
|
|
78
70
|
.where.not(total_cost: nil)
|
|
79
71
|
.order(total_cost: :desc)
|
|
80
|
-
.limit(
|
|
72
|
+
.limit(TOP_LIMIT)
|
|
81
73
|
.map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
|
|
82
74
|
end
|
|
75
|
+
|
|
76
|
+
private_class_method :normalized_days, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
|
|
83
77
|
end
|
|
84
78
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class ReportFormatter
|
|
5
5
|
TOP_LIMIT = 5
|
|
6
|
+
NAME_COLUMN_WIDTH = 28
|
|
7
|
+
TOP_CALL_COLUMN_WIDTH = 32
|
|
6
8
|
|
|
7
9
|
def initialize(data)
|
|
8
10
|
@data = data
|
|
@@ -13,7 +15,7 @@ module LlmCostTracker
|
|
|
13
15
|
append_summary(lines)
|
|
14
16
|
append_cost_section(lines, "By provider", @data.cost_by_provider)
|
|
15
17
|
append_cost_section(lines, "By model", @data.cost_by_model)
|
|
16
|
-
|
|
18
|
+
append_tag_sections(lines)
|
|
17
19
|
append_top_calls(lines)
|
|
18
20
|
lines.join("\n")
|
|
19
21
|
end
|
|
@@ -33,7 +35,13 @@ module LlmCostTracker
|
|
|
33
35
|
return lines << " none" if rows.empty?
|
|
34
36
|
|
|
35
37
|
rows.first(TOP_LIMIT).each do |name, cost|
|
|
36
|
-
lines << " #{name.to_s.ljust(
|
|
38
|
+
lines << " #{name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(cost)}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def append_tag_sections(lines)
|
|
43
|
+
@data.cost_by_tags.each do |tag_key, rows|
|
|
44
|
+
append_cost_section(lines, "By tag (#{tag_key})", rows)
|
|
37
45
|
end
|
|
38
46
|
end
|
|
39
47
|
|
|
@@ -44,7 +52,7 @@ module LlmCostTracker
|
|
|
44
52
|
|
|
45
53
|
@data.top_calls.first(TOP_LIMIT).each do |call|
|
|
46
54
|
label = "#{call.provider}/#{call.model}"
|
|
47
|
-
lines << " #{label.ljust(
|
|
55
|
+
lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
|
|
48
56
|
end
|
|
49
57
|
end
|
|
50
58
|
|
|
@@ -25,10 +25,8 @@ module LlmCostTracker
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def monthly_total(time: Time.now.utc)
|
|
28
|
-
beginning_of_month = Time.new(time.year, time.month, 1, 0, 0, 0, "+00:00")
|
|
29
|
-
|
|
30
28
|
model_class
|
|
31
|
-
.where(tracked_at: beginning_of_month..time)
|
|
29
|
+
.where(tracked_at: time.beginning_of_month..time)
|
|
32
30
|
.sum(:total_cost)
|
|
33
31
|
.to_f
|
|
34
32
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module TagKey
|
|
5
|
+
PATTERN = /\A[\w.-]+\z/
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def validate!(key, error_class: ArgumentError)
|
|
9
|
+
key = key.to_s
|
|
10
|
+
return key if key.match?(PATTERN)
|
|
11
|
+
|
|
12
|
+
raise error_class, "invalid tag key: #{key.inspect}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -60,7 +60,11 @@ module LlmCostTracker
|
|
|
60
60
|
|
|
61
61
|
def store(event)
|
|
62
62
|
config = LlmCostTracker.configuration
|
|
63
|
-
|
|
63
|
+
case config.storage_backend
|
|
64
|
+
when :log then log_event(event, config)
|
|
65
|
+
when :active_record then active_record_save(event)
|
|
66
|
+
when :custom then custom_save(event, config)
|
|
67
|
+
end
|
|
64
68
|
rescue BudgetExceededError, UnknownPricingError
|
|
65
69
|
raise
|
|
66
70
|
rescue StandardError => e
|
|
@@ -68,6 +72,36 @@ module LlmCostTracker
|
|
|
68
72
|
false
|
|
69
73
|
end
|
|
70
74
|
|
|
75
|
+
def log_event(event, config)
|
|
76
|
+
message = "#{event.provider}/#{event.model} " \
|
|
77
|
+
"tokens=#{event.input_tokens}+#{event.output_tokens} " \
|
|
78
|
+
"cost=#{log_cost_label(event)}"
|
|
79
|
+
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
80
|
+
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
81
|
+
|
|
82
|
+
Logging.log(config.log_level, message)
|
|
83
|
+
event
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def log_cost_label(event)
|
|
87
|
+
event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def active_record_save(event)
|
|
91
|
+
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
92
|
+
require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
93
|
+
|
|
94
|
+
Storage::ActiveRecordStore.save(event)
|
|
95
|
+
event
|
|
96
|
+
rescue LoadError => e
|
|
97
|
+
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def custom_save(event, config)
|
|
101
|
+
result = config.custom_storage&.call(event)
|
|
102
|
+
result == false ? false : event
|
|
103
|
+
end
|
|
104
|
+
|
|
71
105
|
def handle_storage_error(error)
|
|
72
106
|
case LlmCostTracker.configuration.storage_error_behavior
|
|
73
107
|
when :ignore
|
|
@@ -27,7 +27,7 @@ module LlmCostTracker
|
|
|
27
27
|
def warn_missing(model)
|
|
28
28
|
Logging.warn(
|
|
29
29
|
"No pricing configured for model #{model.inspect}. " \
|
|
30
|
-
"Cost and budget
|
|
30
|
+
"Cost and budget guardrails will be skipped for this event. " \
|
|
31
31
|
"Add a pricing_overrides entry or set unknown_pricing_behavior."
|
|
32
32
|
)
|
|
33
33
|
end
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -7,7 +7,6 @@ require_relative "llm_cost_tracker/version"
|
|
|
7
7
|
require_relative "llm_cost_tracker/configuration"
|
|
8
8
|
require_relative "llm_cost_tracker/errors"
|
|
9
9
|
require_relative "llm_cost_tracker/logging"
|
|
10
|
-
require_relative "llm_cost_tracker/value_object"
|
|
11
10
|
require_relative "llm_cost_tracker/cost"
|
|
12
11
|
require_relative "llm_cost_tracker/event"
|
|
13
12
|
require_relative "llm_cost_tracker/parsed_usage"
|
|
@@ -25,9 +24,9 @@ require_relative "llm_cost_tracker/budget"
|
|
|
25
24
|
require_relative "llm_cost_tracker/unknown_pricing"
|
|
26
25
|
require_relative "llm_cost_tracker/event_metadata"
|
|
27
26
|
require_relative "llm_cost_tracker/tags_column"
|
|
27
|
+
require_relative "llm_cost_tracker/tag_key"
|
|
28
28
|
require_relative "llm_cost_tracker/tag_query"
|
|
29
29
|
require_relative "llm_cost_tracker/tag_accessors"
|
|
30
|
-
require_relative "llm_cost_tracker/storage/backends"
|
|
31
30
|
require_relative "llm_cost_tracker/tracker"
|
|
32
31
|
require_relative "llm_cost_tracker/report_data"
|
|
33
32
|
require_relative "llm_cost_tracker/report_formatter"
|
|
@@ -35,12 +34,10 @@ require_relative "llm_cost_tracker/report"
|
|
|
35
34
|
|
|
36
35
|
module LlmCostTracker
|
|
37
36
|
class << self
|
|
38
|
-
CONFIGURATION_MUTEX = Mutex.new
|
|
39
|
-
|
|
40
37
|
attr_writer :configuration
|
|
41
38
|
|
|
42
39
|
def configuration
|
|
43
|
-
@configuration
|
|
40
|
+
@configuration ||= Configuration.new
|
|
44
41
|
end
|
|
45
42
|
|
|
46
43
|
# Configure the gem once during application boot.
|
|
@@ -54,7 +51,7 @@ module LlmCostTracker
|
|
|
54
51
|
end
|
|
55
52
|
|
|
56
53
|
def reset_configuration!
|
|
57
|
-
|
|
54
|
+
@configuration = Configuration.new
|
|
58
55
|
end
|
|
59
56
|
|
|
60
57
|
# Track an LLM request manually for non-Faraday clients.
|
data/llm_cost_tracker.gemspec
CHANGED
|
@@ -12,32 +12,36 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.description = "Tracks token usage and estimated costs for OpenAI, Anthropic, Google Gemini, " \
|
|
13
13
|
"OpenRouter, DeepSeek, and OpenAI-compatible calls. " \
|
|
14
14
|
"Works as Faraday middleware for Ruby clients, with ActiveRecord storage, " \
|
|
15
|
-
"
|
|
15
|
+
"arbitrary tag-based attribution, and budget guardrails."
|
|
16
16
|
spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
|
|
17
17
|
spec.license = "MIT"
|
|
18
18
|
|
|
19
|
-
spec.required_ruby_version = ">= 3.
|
|
19
|
+
spec.required_ruby_version = ">= 3.3.0"
|
|
20
20
|
|
|
21
|
-
spec.metadata["homepage_uri"]
|
|
22
|
-
spec.metadata["
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
24
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
23
25
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
24
26
|
|
|
25
27
|
spec.files = Dir.chdir(__dir__) do
|
|
26
28
|
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
29
|
(File.expand_path(f) == __FILE__) ||
|
|
28
|
-
f.start_with?("bin/", "test/", "spec/", ".git", ".github", "Gemfile")
|
|
30
|
+
f.start_with?("bin/", "test/", "spec/", ".git", ".github", "gemfiles/", ".rubocop", "Gemfile")
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
spec.require_paths = ["lib"]
|
|
33
35
|
|
|
34
|
-
spec.add_dependency "activesupport", ">= 7.
|
|
35
|
-
spec.add_dependency "
|
|
36
|
+
spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
|
|
37
|
+
spec.add_dependency "csv", ">= 3.0"
|
|
38
|
+
spec.add_dependency "faraday", ">= 2.0", "< 3.0"
|
|
36
39
|
|
|
37
|
-
spec.add_development_dependency "activerecord", ">= 7.
|
|
40
|
+
spec.add_development_dependency "activerecord", ">= 7.1", "< 9.0"
|
|
41
|
+
spec.add_development_dependency "railties", ">= 7.1", "< 9.0"
|
|
38
42
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
39
43
|
spec.add_development_dependency "rspec", "~> 3.0"
|
|
40
44
|
spec.add_development_dependency "rubocop", "~> 1.0"
|
|
41
|
-
spec.add_development_dependency "sqlite3", "
|
|
45
|
+
spec.add_development_dependency "sqlite3", ">= 1.4", "< 3.0"
|
|
42
46
|
spec.add_development_dependency "webmock", "~> 3.0"
|
|
43
47
|
end
|