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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +11 -7
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
  5. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
  6. data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
  8. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
  9. data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
  10. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
  11. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
  12. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
  13. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
  14. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  15. data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
  16. data/lib/llm_cost_tracker/configuration.rb +22 -16
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +7 -1
  19. data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
  20. data/lib/llm_cost_tracker/integrations/base.rb +77 -6
  21. data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
  22. data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
  23. data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
  24. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
  25. data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
  26. data/lib/llm_cost_tracker/middleware/faraday.rb +8 -4
  27. data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
  28. data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
  29. data/lib/llm_cost_tracker/price_registry.rb +3 -0
  30. data/lib/llm_cost_tracker/price_sync/fetcher.rb +41 -12
  31. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
  32. data/lib/llm_cost_tracker/report.rb +8 -1
  33. data/lib/llm_cost_tracker/report_data.rb +25 -9
  34. data/lib/llm_cost_tracker/retention.rb +30 -7
  35. data/lib/llm_cost_tracker/stream_capture.rb +7 -0
  36. data/lib/llm_cost_tracker/stream_collector.rb +25 -1
  37. data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
  38. data/lib/llm_cost_tracker/tracker.rb +6 -2
  39. data/lib/llm_cost_tracker/version.rb +1 -1
  40. data/lib/llm_cost_tracker.rb +1 -0
  41. 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).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
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
- when Time, DateTime then older_than.utc
26
- when ActiveSupport::Duration then now - older_than
27
- when Integer then now - (older_than * 86_400)
28
- else
29
- raise ArgumentError, "older_than must be a Duration, Time, or Integer days: #{older_than.inspect}"
30
- end
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module StreamCapture
5
+ LIMIT_BYTES = 1_048_576
6
+ end
7
+ 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
- @events << { event: type, data: ValueHelpers.deep_dup(data) } unless data.nil?
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: LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)).freeze,
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.2"
5
5
  end
@@ -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.1
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 OpenAI, Anthropic,
226
- Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works through Faraday
227
- middleware or explicit track/track_stream helpers, with ActiveRecord storage, tag-based
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