llm_cost_tracker 0.1.2 → 0.1.4
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 +48 -0
- data/README.md +149 -32
- data/lib/llm_cost_tracker/budget.rb +7 -19
- data/lib/llm_cost_tracker/configuration.rb +52 -10
- data/lib/llm_cost_tracker/cost.rb +15 -0
- data/lib/llm_cost_tracker/event.rb +24 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +36 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +56 -44
- data/lib/llm_cost_tracker/logging.rb +44 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +15 -12
- data/lib/llm_cost_tracker/parsed_usage.rb +45 -0
- data/lib/llm_cost_tracker/parsers/anthropic.rb +2 -3
- data/lib/llm_cost_tracker/parsers/base.rb +2 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -4
- data/lib/llm_cost_tracker/parsers/openai.rb +4 -22
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -8
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +33 -0
- data/lib/llm_cost_tracker/price_registry.rb +36 -6
- data/lib/llm_cost_tracker/pricing.rb +36 -10
- data/lib/llm_cost_tracker/railtie.rb +5 -0
- data/lib/llm_cost_tracker/report.rb +29 -0
- data/lib/llm_cost_tracker/report_data.rb +81 -0
- data/lib/llm_cost_tracker/report_formatter.rb +65 -0
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +19 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +11 -11
- data/lib/llm_cost_tracker/storage/backends.rb +26 -0
- data/lib/llm_cost_tracker/storage/custom_backend.rb +16 -0
- data/lib/llm_cost_tracker/storage/log_backend.rb +28 -0
- data/lib/llm_cost_tracker/tag_accessors.rb +15 -0
- data/lib/llm_cost_tracker/tag_query.rb +38 -0
- data/lib/llm_cost_tracker/tags_column.rb +16 -0
- data/lib/llm_cost_tracker/tracker.rb +18 -67
- data/lib/llm_cost_tracker/unknown_pricing.rb +8 -15
- data/lib/llm_cost_tracker/value_object.rb +45 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +28 -13
- data/lib/tasks/llm_cost_tracker.rake +9 -0
- data/llm_cost_tracker.gemspec +1 -1
- metadata +22 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "report_data"
|
|
4
|
+
require_relative "report_formatter"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
class Report
|
|
8
|
+
DEFAULT_DAYS = ReportData::DEFAULT_DAYS
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Render a terminal-friendly cost report from ActiveRecord storage.
|
|
12
|
+
#
|
|
13
|
+
# @param days [Integer] Number of trailing days to include.
|
|
14
|
+
# @param now [Time] Report end time.
|
|
15
|
+
# @return [String]
|
|
16
|
+
def generate(days: DEFAULT_DAYS, now: Time.now.utc)
|
|
17
|
+
ReportFormatter.new(data(days: days, now: now)).to_s
|
|
18
|
+
rescue LoadError => e
|
|
19
|
+
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def data(days: DEFAULT_DAYS, now: Time.now.utc)
|
|
25
|
+
ReportData.build(days: days, now: now)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "value_object"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
TopCall = ValueObject.define(:provider, :model, :total_cost)
|
|
7
|
+
|
|
8
|
+
ReportData = ValueObject.define(
|
|
9
|
+
:days,
|
|
10
|
+
:from_time,
|
|
11
|
+
:to_time,
|
|
12
|
+
:total_cost,
|
|
13
|
+
:requests_count,
|
|
14
|
+
:average_latency_ms,
|
|
15
|
+
:unknown_pricing_count,
|
|
16
|
+
:cost_by_provider,
|
|
17
|
+
:cost_by_model,
|
|
18
|
+
:cost_by_tags,
|
|
19
|
+
:top_calls
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ReportData.const_set(:DEFAULT_DAYS, 30)
|
|
23
|
+
ReportData.const_set(:TOP_LIMIT, 5)
|
|
24
|
+
ReportData.const_set(:DEFAULT_TAG_BREAKDOWNS, %w[feature].freeze)
|
|
25
|
+
|
|
26
|
+
class << ReportData
|
|
27
|
+
def build(days: ReportData::DEFAULT_DAYS, now: Time.now.utc)
|
|
28
|
+
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
29
|
+
|
|
30
|
+
days = normalized_days(days)
|
|
31
|
+
scope = LlmApiCall.where(tracked_at: from_time(days, now)..now)
|
|
32
|
+
|
|
33
|
+
new(
|
|
34
|
+
days: days,
|
|
35
|
+
from_time: from_time(days, now),
|
|
36
|
+
to_time: now,
|
|
37
|
+
total_cost: scope.sum(:total_cost).to_f,
|
|
38
|
+
requests_count: scope.count,
|
|
39
|
+
average_latency_ms: average_latency_ms(scope),
|
|
40
|
+
unknown_pricing_count: scope.where(total_cost: nil).count,
|
|
41
|
+
cost_by_provider: cost_by(scope, :provider),
|
|
42
|
+
cost_by_model: cost_by(scope, :model),
|
|
43
|
+
cost_by_tags: cost_by_tags(scope, ReportData::DEFAULT_TAG_BREAKDOWNS),
|
|
44
|
+
top_calls: top_calls(scope)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def normalized_days(days)
|
|
51
|
+
days = days.to_i
|
|
52
|
+
days.positive? ? days : ReportData::DEFAULT_DAYS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def from_time(days, now)
|
|
56
|
+
now - (days * 86_400)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def average_latency_ms(scope)
|
|
60
|
+
return nil unless LlmApiCall.latency_column?
|
|
61
|
+
|
|
62
|
+
scope.average(:latency_ms)&.to_f
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cost_by(scope, column)
|
|
66
|
+
scope.group(column).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cost_by_tags(scope, keys)
|
|
70
|
+
keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def top_calls(scope)
|
|
74
|
+
scope
|
|
75
|
+
.where.not(total_cost: nil)
|
|
76
|
+
.order(total_cost: :desc)
|
|
77
|
+
.limit(ReportData::TOP_LIMIT)
|
|
78
|
+
.map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class ReportFormatter
|
|
5
|
+
TOP_LIMIT = 5
|
|
6
|
+
|
|
7
|
+
def initialize(data)
|
|
8
|
+
@data = data
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
lines = ["LLM Cost Report (last #{@data.days} days)", ""]
|
|
13
|
+
append_summary(lines)
|
|
14
|
+
append_cost_section(lines, "By provider", @data.cost_by_provider)
|
|
15
|
+
append_cost_section(lines, "By model", @data.cost_by_model)
|
|
16
|
+
append_tag_sections(lines)
|
|
17
|
+
append_top_calls(lines)
|
|
18
|
+
lines.join("\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def append_summary(lines)
|
|
24
|
+
lines << "Total cost: #{money(@data.total_cost)}"
|
|
25
|
+
lines << "Requests: #{@data.requests_count}"
|
|
26
|
+
lines << "Avg latency: #{average_latency}"
|
|
27
|
+
lines << "Unknown pricing: #{@data.unknown_pricing_count}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def append_cost_section(lines, title, rows)
|
|
31
|
+
lines << ""
|
|
32
|
+
lines << "#{title}:"
|
|
33
|
+
return lines << " none" if rows.empty?
|
|
34
|
+
|
|
35
|
+
rows.first(TOP_LIMIT).each do |name, cost|
|
|
36
|
+
lines << " #{name.to_s.ljust(28)} #{money(cost)}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def append_tag_sections(lines)
|
|
41
|
+
@data.cost_by_tags.each do |tag_key, rows|
|
|
42
|
+
append_cost_section(lines, "By tag (#{tag_key})", rows)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def append_top_calls(lines)
|
|
47
|
+
lines << ""
|
|
48
|
+
lines << "Top expensive calls:"
|
|
49
|
+
return lines << " none" if @data.top_calls.empty?
|
|
50
|
+
|
|
51
|
+
@data.top_calls.first(TOP_LIMIT).each do |call|
|
|
52
|
+
label = "#{call.provider}/#{call.model}"
|
|
53
|
+
lines << " #{label.ljust(32)} #{money(call.total_cost)}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def average_latency
|
|
58
|
+
@data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def money(value)
|
|
62
|
+
"$#{format('%.6f', value.to_f)}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Storage
|
|
5
|
+
module ActiveRecordBackend
|
|
6
|
+
class << self
|
|
7
|
+
def save(event, **_options)
|
|
8
|
+
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
9
|
+
require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
10
|
+
|
|
11
|
+
ActiveRecordStore.save(event)
|
|
12
|
+
event
|
|
13
|
+
rescue LoadError => e
|
|
14
|
+
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -5,21 +5,21 @@ module LlmCostTracker
|
|
|
5
5
|
class ActiveRecordStore
|
|
6
6
|
class << self
|
|
7
7
|
def save(event)
|
|
8
|
-
tags = stringify_tags(event
|
|
8
|
+
tags = stringify_tags(event.tags || {})
|
|
9
9
|
|
|
10
10
|
attributes = {
|
|
11
|
-
provider: event
|
|
12
|
-
model: event
|
|
13
|
-
input_tokens: event
|
|
14
|
-
output_tokens: event
|
|
15
|
-
total_tokens: event
|
|
16
|
-
input_cost: event.
|
|
17
|
-
output_cost: event.
|
|
18
|
-
total_cost: event.
|
|
11
|
+
provider: event.provider,
|
|
12
|
+
model: event.model,
|
|
13
|
+
input_tokens: event.input_tokens,
|
|
14
|
+
output_tokens: event.output_tokens,
|
|
15
|
+
total_tokens: event.total_tokens,
|
|
16
|
+
input_cost: event.cost&.input_cost,
|
|
17
|
+
output_cost: event.cost&.output_cost,
|
|
18
|
+
total_cost: event.cost&.total_cost,
|
|
19
19
|
tags: tags_for_storage(tags),
|
|
20
|
-
tracked_at: event
|
|
20
|
+
tracked_at: event.tracked_at
|
|
21
21
|
}
|
|
22
|
-
attributes[:latency_ms] = event
|
|
22
|
+
attributes[:latency_ms] = event.latency_ms if model_class.latency_column?
|
|
23
23
|
|
|
24
24
|
model_class.create!(attributes)
|
|
25
25
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "log_backend"
|
|
5
|
+
require_relative "active_record_backend"
|
|
6
|
+
require_relative "custom_backend"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Storage
|
|
10
|
+
module Backends
|
|
11
|
+
MAP = {
|
|
12
|
+
log: LogBackend,
|
|
13
|
+
active_record: ActiveRecordBackend,
|
|
14
|
+
custom: CustomBackend
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def fetch(name)
|
|
19
|
+
MAP.fetch(name.to_sym)
|
|
20
|
+
rescue KeyError
|
|
21
|
+
raise Error, "Unknown storage_backend: #{name.inspect}. Use one of: #{MAP.keys.join(', ')}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Storage
|
|
5
|
+
module CustomBackend
|
|
6
|
+
class << self
|
|
7
|
+
def save(event, config:)
|
|
8
|
+
result = config.custom_storage&.call(event)
|
|
9
|
+
return false if result == false
|
|
10
|
+
|
|
11
|
+
event
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../logging"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
module LogBackend
|
|
8
|
+
class << self
|
|
9
|
+
def save(event, config:)
|
|
10
|
+
message = "#{event.provider}/#{event.model} " \
|
|
11
|
+
"tokens=#{event.input_tokens}+#{event.output_tokens} " \
|
|
12
|
+
"cost=#{cost_label(event)}"
|
|
13
|
+
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
14
|
+
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
15
|
+
|
|
16
|
+
Logging.log(config.log_level, message)
|
|
17
|
+
event
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def cost_label(event)
|
|
23
|
+
event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module TagAccessors
|
|
7
|
+
def parsed_tags
|
|
8
|
+
return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
JSON.parse(tags || "{}")
|
|
11
|
+
rescue JSON::ParserError
|
|
12
|
+
{}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module TagQuery
|
|
7
|
+
class << self
|
|
8
|
+
def apply(model, tags)
|
|
9
|
+
normalized_tags = normalize_tags(tags)
|
|
10
|
+
return model.all if normalized_tags.empty?
|
|
11
|
+
|
|
12
|
+
return json_query(model, normalized_tags) if model.tags_json_column?
|
|
13
|
+
|
|
14
|
+
text_query(model, normalized_tags)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def normalize_tags(tags)
|
|
18
|
+
(tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def json_query(model, tags)
|
|
24
|
+
model.where("tags @> ?::jsonb", tags.to_json)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def text_query(model, tags)
|
|
28
|
+
tags.reduce(model.all) do |relation, (key, value)|
|
|
29
|
+
relation.where("tags LIKE ? ESCAPE '\\'", "%#{model.sanitize_sql_like(json_tag_fragment(key, value))}%")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def json_tag_fragment(key, value)
|
|
34
|
+
JSON.generate(key => value).delete_prefix("{").delete_suffix("}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module TagsColumn
|
|
5
|
+
def tags_json_column?
|
|
6
|
+
column = columns_hash["tags"]
|
|
7
|
+
return false unless column
|
|
8
|
+
|
|
9
|
+
%i[json jsonb].include?(column.type) || column.sql_type.to_s.downcase == "jsonb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def latency_column?
|
|
13
|
+
columns_hash.key?("latency_ms")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "logging"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
class Tracker
|
|
5
7
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
@@ -9,6 +11,15 @@ module LlmCostTracker
|
|
|
9
11
|
Budget.enforce!
|
|
10
12
|
end
|
|
11
13
|
|
|
14
|
+
# Build, notify, persist, and budget-check a single LLM usage event.
|
|
15
|
+
#
|
|
16
|
+
# @param provider [String] Provider name.
|
|
17
|
+
# @param model [String] Model identifier.
|
|
18
|
+
# @param input_tokens [Integer] Input token count.
|
|
19
|
+
# @param output_tokens [Integer] Output token count.
|
|
20
|
+
# @param metadata [Hash] Attribution tags plus provider-specific usage metadata.
|
|
21
|
+
# @param latency_ms [Integer, nil] Optional latency in milliseconds.
|
|
22
|
+
# @return [LlmCostTracker::Event]
|
|
12
23
|
def record(provider:, model:, input_tokens:, output_tokens:, metadata: {}, latency_ms: nil)
|
|
13
24
|
usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
|
|
14
25
|
|
|
@@ -23,20 +34,20 @@ module LlmCostTracker
|
|
|
23
34
|
|
|
24
35
|
UnknownPricing.handle!(model) unless cost_data
|
|
25
36
|
|
|
26
|
-
event =
|
|
37
|
+
event = Event.new(
|
|
27
38
|
provider: provider,
|
|
28
39
|
model: model,
|
|
29
40
|
input_tokens: usage[:input_tokens],
|
|
30
41
|
output_tokens: usage[:output_tokens],
|
|
31
42
|
total_tokens: usage[:total_tokens],
|
|
32
43
|
cost: cost_data,
|
|
33
|
-
tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)),
|
|
44
|
+
tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)).freeze,
|
|
34
45
|
latency_ms: normalized_latency_ms(latency_ms),
|
|
35
46
|
tracked_at: Time.now.utc
|
|
36
|
-
|
|
47
|
+
)
|
|
37
48
|
|
|
38
49
|
# Emit ActiveSupport::Notifications event
|
|
39
|
-
ActiveSupport::Notifications.instrument(EVENT_NAME, event)
|
|
50
|
+
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
40
51
|
|
|
41
52
|
# Store based on backend
|
|
42
53
|
stored = store(event)
|
|
@@ -49,17 +60,7 @@ module LlmCostTracker
|
|
|
49
60
|
|
|
50
61
|
def store(event)
|
|
51
62
|
config = LlmCostTracker.configuration
|
|
52
|
-
|
|
53
|
-
case config.storage_backend
|
|
54
|
-
when :log
|
|
55
|
-
log_event(event)
|
|
56
|
-
when :active_record
|
|
57
|
-
store_active_record(event)
|
|
58
|
-
when :custom
|
|
59
|
-
config.custom_storage&.call(event)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
true
|
|
63
|
+
Storage::Backends.fetch(config.storage_backend).save(event, config: config)
|
|
63
64
|
rescue BudgetExceededError, UnknownPricingError
|
|
64
65
|
raise
|
|
65
66
|
rescue StandardError => e
|
|
@@ -67,68 +68,18 @@ module LlmCostTracker
|
|
|
67
68
|
false
|
|
68
69
|
end
|
|
69
70
|
|
|
70
|
-
def log_event(event)
|
|
71
|
-
cost_str = event[:cost] ? "$#{format('%.6f', event[:cost][:total_cost])}" : "unknown"
|
|
72
|
-
|
|
73
|
-
message = "[LlmCostTracker] #{event[:provider]}/#{event[:model]} " \
|
|
74
|
-
"tokens=#{event[:input_tokens]}+#{event[:output_tokens]} " \
|
|
75
|
-
"cost=#{cost_str}"
|
|
76
|
-
message += " latency=#{event[:latency_ms]}ms" if event[:latency_ms]
|
|
77
|
-
message += " tags=#{event[:tags]}" unless event[:tags].empty?
|
|
78
|
-
|
|
79
|
-
case LlmCostTracker.configuration.log_level
|
|
80
|
-
when :debug
|
|
81
|
-
Rails.logger.debug(message) if defined?(Rails)
|
|
82
|
-
when :warn
|
|
83
|
-
Rails.logger.warn(message) if defined?(Rails)
|
|
84
|
-
else
|
|
85
|
-
Rails.logger.info(message) if defined?(Rails)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Fallback if Rails is not available
|
|
89
|
-
warn(message) unless defined?(Rails)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def log_warning(message)
|
|
93
|
-
message = "[LlmCostTracker] #{message}"
|
|
94
|
-
|
|
95
|
-
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
96
|
-
Rails.logger.warn(message)
|
|
97
|
-
else
|
|
98
|
-
warn message
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def store_active_record(event)
|
|
103
|
-
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
104
|
-
require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
105
|
-
|
|
106
|
-
LlmCostTracker::Storage::ActiveRecordStore.save(event)
|
|
107
|
-
rescue LoadError => e
|
|
108
|
-
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
109
|
-
end
|
|
110
|
-
|
|
111
71
|
def handle_storage_error(error)
|
|
112
|
-
case storage_error_behavior
|
|
72
|
+
case LlmCostTracker.configuration.storage_error_behavior
|
|
113
73
|
when :ignore
|
|
114
74
|
nil
|
|
115
75
|
when :warn
|
|
116
|
-
|
|
76
|
+
Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
|
|
117
77
|
when :raise
|
|
118
78
|
storage_error = StorageError.new(error)
|
|
119
79
|
raise storage_error
|
|
120
80
|
end
|
|
121
81
|
end
|
|
122
82
|
|
|
123
|
-
def storage_error_behavior
|
|
124
|
-
behavior = (LlmCostTracker.configuration.storage_error_behavior || :warn).to_sym
|
|
125
|
-
return behavior if Configuration::STORAGE_ERROR_BEHAVIORS.include?(behavior)
|
|
126
|
-
|
|
127
|
-
raise Error,
|
|
128
|
-
"Unknown storage_error_behavior: #{behavior.inspect}. " \
|
|
129
|
-
"Use one of: #{Configuration::STORAGE_ERROR_BEHAVIORS.join(', ')}"
|
|
130
|
-
end
|
|
131
|
-
|
|
132
83
|
def normalized_latency_ms(latency_ms)
|
|
133
84
|
return nil if latency_ms.nil?
|
|
134
85
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "logging"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
class UnknownPricing
|
|
5
7
|
class << self
|
|
@@ -23,24 +25,15 @@ module LlmCostTracker
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def warn_missing(model)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Rails.logger.warn(message)
|
|
32
|
-
else
|
|
33
|
-
Kernel.warn(message)
|
|
34
|
-
end
|
|
28
|
+
Logging.warn(
|
|
29
|
+
"No pricing configured for model #{model.inspect}. " \
|
|
30
|
+
"Cost and budget guardrails will be skipped for this event. " \
|
|
31
|
+
"Add a pricing_overrides entry or set unknown_pricing_behavior."
|
|
32
|
+
)
|
|
35
33
|
end
|
|
36
34
|
|
|
37
35
|
def behavior
|
|
38
|
-
|
|
39
|
-
return behavior if Configuration::UNKNOWN_PRICING_BEHAVIORS.include?(behavior)
|
|
40
|
-
|
|
41
|
-
raise Error,
|
|
42
|
-
"Unknown unknown_pricing_behavior: #{behavior.inspect}. " \
|
|
43
|
-
"Use one of: #{Configuration::UNKNOWN_PRICING_BEHAVIORS.join(', ')}"
|
|
36
|
+
LlmCostTracker.configuration.unknown_pricing_behavior
|
|
44
37
|
end
|
|
45
38
|
end
|
|
46
39
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ValueObject
|
|
5
|
+
class << self
|
|
6
|
+
def define(*members, &block)
|
|
7
|
+
klass = data_class(*members)
|
|
8
|
+
add_hash_like_readers(klass)
|
|
9
|
+
klass.class_eval(&block) if block
|
|
10
|
+
klass
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def data_class(*members)
|
|
16
|
+
return Data.define(*members) if defined?(Data)
|
|
17
|
+
|
|
18
|
+
Struct.new(*members, keyword_init: true) do
|
|
19
|
+
def initialize(**kwargs)
|
|
20
|
+
super
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_hash_like_readers(klass)
|
|
27
|
+
klass.class_eval do
|
|
28
|
+
def [](key)
|
|
29
|
+
public_send(key.to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dig(key, *rest)
|
|
33
|
+
value = self[key]
|
|
34
|
+
rest.empty? ? value : value&.dig(*rest)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def except(*keys)
|
|
38
|
+
excluded = keys.map(&:to_sym)
|
|
39
|
+
to_h.reject { |key, _value| excluded.include?(key.to_sym) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|