llm_cost_tracker 0.5.1 → 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 +27 -0
- data/README.md +11 -7
- 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/generators/llm_cost_tracker/install_generator.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +7 -1
- 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 +8 -4
- 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_registry.rb +3 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +41 -12
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
- 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/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 +6 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- metadata +9 -5
|
@@ -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
|
|
@@ -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
|
|
@@ -6,7 +6,7 @@ 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!
|
|
@@ -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),
|
|
@@ -95,6 +95,10 @@ module LlmCostTracker
|
|
|
95
95
|
|
|
96
96
|
def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
|
|
97
97
|
|
|
98
|
+
def sanitized_tags(metadata)
|
|
99
|
+
LlmCostTracker::TagSanitizer.call(LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)))
|
|
100
|
+
end
|
|
101
|
+
|
|
98
102
|
def normalized_usage_source(value)
|
|
99
103
|
return nil if value.nil?
|
|
100
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"
|
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.5.
|
|
4
|
+
version: 0.5.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
@@ -222,10 +222,10 @@ dependencies:
|
|
|
222
222
|
- - "~>"
|
|
223
223
|
- !ruby/object:Gem::Version
|
|
224
224
|
version: '3.0'
|
|
225
|
-
description: Tracks token usage, latency, and estimated costs for
|
|
226
|
-
Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works
|
|
227
|
-
middleware or explicit track/track_stream helpers, with ActiveRecord
|
|
228
|
-
attribution, price sync tasks, and budget guardrails.
|
|
225
|
+
description: Tracks token usage, latency, and estimated costs for RubyLLM, OpenAI,
|
|
226
|
+
Anthropic, Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works
|
|
227
|
+
through Faraday middleware or explicit track/track_stream helpers, with ActiveRecord
|
|
228
|
+
storage, tag-based attribution, price sync tasks, and budget guardrails.
|
|
229
229
|
email:
|
|
230
230
|
- sergey@mm.st
|
|
231
231
|
executables: []
|
|
@@ -255,6 +255,7 @@ files:
|
|
|
255
255
|
- app/helpers/llm_cost_tracker/pagination_helper.rb
|
|
256
256
|
- app/services/llm_cost_tracker/dashboard/data_quality.rb
|
|
257
257
|
- app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb
|
|
258
|
+
- app/services/llm_cost_tracker/dashboard/date_range.rb
|
|
258
259
|
- app/services/llm_cost_tracker/dashboard/filter.rb
|
|
259
260
|
- app/services/llm_cost_tracker/dashboard/overview_stats.rb
|
|
260
261
|
- app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
|
|
@@ -317,6 +318,7 @@ files:
|
|
|
317
318
|
- lib/llm_cost_tracker/integrations/object_reader.rb
|
|
318
319
|
- lib/llm_cost_tracker/integrations/openai.rb
|
|
319
320
|
- lib/llm_cost_tracker/integrations/registry.rb
|
|
321
|
+
- lib/llm_cost_tracker/integrations/ruby_llm.rb
|
|
320
322
|
- lib/llm_cost_tracker/llm_api_call.rb
|
|
321
323
|
- lib/llm_cost_tracker/logging.rb
|
|
322
324
|
- lib/llm_cost_tracker/middleware/faraday.rb
|
|
@@ -350,11 +352,13 @@ files:
|
|
|
350
352
|
- lib/llm_cost_tracker/storage/active_record_rollups.rb
|
|
351
353
|
- lib/llm_cost_tracker/storage/active_record_store.rb
|
|
352
354
|
- lib/llm_cost_tracker/storage/dispatcher.rb
|
|
355
|
+
- lib/llm_cost_tracker/stream_capture.rb
|
|
353
356
|
- lib/llm_cost_tracker/stream_collector.rb
|
|
354
357
|
- lib/llm_cost_tracker/tag_accessors.rb
|
|
355
358
|
- lib/llm_cost_tracker/tag_context.rb
|
|
356
359
|
- lib/llm_cost_tracker/tag_key.rb
|
|
357
360
|
- lib/llm_cost_tracker/tag_query.rb
|
|
361
|
+
- lib/llm_cost_tracker/tag_sanitizer.rb
|
|
358
362
|
- lib/llm_cost_tracker/tags_column.rb
|
|
359
363
|
- lib/llm_cost_tracker/tracker.rb
|
|
360
364
|
- lib/llm_cost_tracker/unknown_pricing.rb
|