llm_cost_tracker 0.1.2 → 0.1.3
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 +32 -0
- data/README.md +136 -24
- 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 +15 -50
- 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 +84 -0
- data/lib/llm_cost_tracker/report_formatter.rb +59 -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 +23 -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
- metadata +20 -1
|
@@ -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,23 @@
|
|
|
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
|
+
|
|
15
|
+
def feature
|
|
16
|
+
parsed_tags["feature"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def user_id
|
|
20
|
+
parsed_tags["user_id"]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
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 enforcement 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
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -6,9 +6,15 @@ require "active_support/notifications"
|
|
|
6
6
|
require_relative "llm_cost_tracker/version"
|
|
7
7
|
require_relative "llm_cost_tracker/configuration"
|
|
8
8
|
require_relative "llm_cost_tracker/errors"
|
|
9
|
+
require_relative "llm_cost_tracker/logging"
|
|
10
|
+
require_relative "llm_cost_tracker/value_object"
|
|
11
|
+
require_relative "llm_cost_tracker/cost"
|
|
12
|
+
require_relative "llm_cost_tracker/event"
|
|
13
|
+
require_relative "llm_cost_tracker/parsed_usage"
|
|
9
14
|
require_relative "llm_cost_tracker/price_registry"
|
|
10
15
|
require_relative "llm_cost_tracker/pricing"
|
|
11
16
|
require_relative "llm_cost_tracker/parsers/base"
|
|
17
|
+
require_relative "llm_cost_tracker/parsers/openai_usage"
|
|
12
18
|
require_relative "llm_cost_tracker/parsers/openai"
|
|
13
19
|
require_relative "llm_cost_tracker/parsers/openai_compatible"
|
|
14
20
|
require_relative "llm_cost_tracker/parsers/anthropic"
|
|
@@ -18,7 +24,14 @@ require_relative "llm_cost_tracker/middleware/faraday"
|
|
|
18
24
|
require_relative "llm_cost_tracker/budget"
|
|
19
25
|
require_relative "llm_cost_tracker/unknown_pricing"
|
|
20
26
|
require_relative "llm_cost_tracker/event_metadata"
|
|
27
|
+
require_relative "llm_cost_tracker/tags_column"
|
|
28
|
+
require_relative "llm_cost_tracker/tag_query"
|
|
29
|
+
require_relative "llm_cost_tracker/tag_accessors"
|
|
30
|
+
require_relative "llm_cost_tracker/storage/backends"
|
|
21
31
|
require_relative "llm_cost_tracker/tracker"
|
|
32
|
+
require_relative "llm_cost_tracker/report_data"
|
|
33
|
+
require_relative "llm_cost_tracker/report_formatter"
|
|
34
|
+
require_relative "llm_cost_tracker/report"
|
|
22
35
|
|
|
23
36
|
module LlmCostTracker
|
|
24
37
|
class << self
|
|
@@ -30,6 +43,10 @@ module LlmCostTracker
|
|
|
30
43
|
@configuration || CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
|
|
31
44
|
end
|
|
32
45
|
|
|
46
|
+
# Configure the gem once during application boot.
|
|
47
|
+
#
|
|
48
|
+
# @yieldparam configuration [LlmCostTracker::Configuration]
|
|
49
|
+
# @return [void]
|
|
33
50
|
def configure
|
|
34
51
|
yield(configuration)
|
|
35
52
|
configuration.normalize_openai_compatible_providers!
|
|
@@ -40,7 +57,7 @@ module LlmCostTracker
|
|
|
40
57
|
CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
|
|
41
58
|
end
|
|
42
59
|
|
|
43
|
-
#
|
|
60
|
+
# Track an LLM request manually for non-Faraday clients.
|
|
44
61
|
#
|
|
45
62
|
# LlmCostTracker.track(
|
|
46
63
|
# provider: :openai,
|
|
@@ -50,6 +67,14 @@ module LlmCostTracker
|
|
|
50
67
|
# feature: "chat",
|
|
51
68
|
# user_id: current_user.id
|
|
52
69
|
# )
|
|
70
|
+
#
|
|
71
|
+
# @param provider [String, Symbol] Provider name, such as :openai or :anthropic.
|
|
72
|
+
# @param model [String] Provider model identifier.
|
|
73
|
+
# @param input_tokens [Integer] Billed input token count.
|
|
74
|
+
# @param output_tokens [Integer] Billed output token count.
|
|
75
|
+
# @param latency_ms [Integer, nil] Optional request latency in milliseconds.
|
|
76
|
+
# @param metadata [Hash] Attribution tags and provider-specific usage metadata.
|
|
77
|
+
# @return [LlmCostTracker::Event] The tracked event.
|
|
53
78
|
def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, **metadata)
|
|
54
79
|
Tracker.record(
|
|
55
80
|
provider: provider.to_s,
|
|
@@ -64,20 +89,10 @@ module LlmCostTracker
|
|
|
64
89
|
private
|
|
65
90
|
|
|
66
91
|
def warn_for_configuration!
|
|
67
|
-
return unless
|
|
92
|
+
return unless configuration.budget_exceeded_behavior == :block_requests
|
|
68
93
|
return if configuration.active_record?
|
|
69
94
|
|
|
70
|
-
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def log_warning(message)
|
|
74
|
-
message = "[LlmCostTracker] #{message}"
|
|
75
|
-
|
|
76
|
-
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
77
|
-
Rails.logger.warn(message)
|
|
78
|
-
else
|
|
79
|
-
warn message
|
|
80
|
-
end
|
|
95
|
+
Logging.warn(":block_requests requires storage_backend = :active_record; preflight blocking will be skipped.")
|
|
81
96
|
end
|
|
82
97
|
end
|
|
83
98
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :llm_cost_tracker do
|
|
4
|
+
desc "Print an LLM cost report from ActiveRecord storage"
|
|
5
|
+
task report: :environment do
|
|
6
|
+
days = (ENV["DAYS"] || LlmCostTracker::Report::DEFAULT_DAYS).to_i
|
|
7
|
+
puts LlmCostTracker::Report.generate(days: days)
|
|
8
|
+
end
|
|
9
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_cost_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
@@ -159,33 +159,52 @@ files:
|
|
|
159
159
|
- lib/llm_cost_tracker.rb
|
|
160
160
|
- lib/llm_cost_tracker/budget.rb
|
|
161
161
|
- lib/llm_cost_tracker/configuration.rb
|
|
162
|
+
- lib/llm_cost_tracker/cost.rb
|
|
162
163
|
- lib/llm_cost_tracker/errors.rb
|
|
164
|
+
- lib/llm_cost_tracker/event.rb
|
|
163
165
|
- lib/llm_cost_tracker/event_metadata.rb
|
|
164
166
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb
|
|
165
167
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
|
|
168
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
|
|
166
169
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb
|
|
167
170
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
|
|
168
171
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
|
|
172
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
|
|
169
173
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb
|
|
170
174
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb
|
|
171
175
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb
|
|
172
176
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb
|
|
173
177
|
- lib/llm_cost_tracker/llm_api_call.rb
|
|
178
|
+
- lib/llm_cost_tracker/logging.rb
|
|
174
179
|
- lib/llm_cost_tracker/middleware/faraday.rb
|
|
180
|
+
- lib/llm_cost_tracker/parsed_usage.rb
|
|
175
181
|
- lib/llm_cost_tracker/parsers/anthropic.rb
|
|
176
182
|
- lib/llm_cost_tracker/parsers/base.rb
|
|
177
183
|
- lib/llm_cost_tracker/parsers/gemini.rb
|
|
178
184
|
- lib/llm_cost_tracker/parsers/openai.rb
|
|
179
185
|
- lib/llm_cost_tracker/parsers/openai_compatible.rb
|
|
186
|
+
- lib/llm_cost_tracker/parsers/openai_usage.rb
|
|
180
187
|
- lib/llm_cost_tracker/parsers/registry.rb
|
|
181
188
|
- lib/llm_cost_tracker/price_registry.rb
|
|
182
189
|
- lib/llm_cost_tracker/prices.json
|
|
183
190
|
- lib/llm_cost_tracker/pricing.rb
|
|
184
191
|
- lib/llm_cost_tracker/railtie.rb
|
|
192
|
+
- lib/llm_cost_tracker/report.rb
|
|
193
|
+
- lib/llm_cost_tracker/report_data.rb
|
|
194
|
+
- lib/llm_cost_tracker/report_formatter.rb
|
|
195
|
+
- lib/llm_cost_tracker/storage/active_record_backend.rb
|
|
185
196
|
- lib/llm_cost_tracker/storage/active_record_store.rb
|
|
197
|
+
- lib/llm_cost_tracker/storage/backends.rb
|
|
198
|
+
- lib/llm_cost_tracker/storage/custom_backend.rb
|
|
199
|
+
- lib/llm_cost_tracker/storage/log_backend.rb
|
|
200
|
+
- lib/llm_cost_tracker/tag_accessors.rb
|
|
201
|
+
- lib/llm_cost_tracker/tag_query.rb
|
|
202
|
+
- lib/llm_cost_tracker/tags_column.rb
|
|
186
203
|
- lib/llm_cost_tracker/tracker.rb
|
|
187
204
|
- lib/llm_cost_tracker/unknown_pricing.rb
|
|
205
|
+
- lib/llm_cost_tracker/value_object.rb
|
|
188
206
|
- lib/llm_cost_tracker/version.rb
|
|
207
|
+
- lib/tasks/llm_cost_tracker.rake
|
|
189
208
|
- llm_cost_tracker.gemspec
|
|
190
209
|
homepage: https://github.com/sergey-homenko/llm_cost_tracker
|
|
191
210
|
licenses:
|