llm_cost_tracker 0.5.0 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +116 -467
  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/doctor.rb +1 -1
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
  20. data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
  21. data/lib/llm_cost_tracker/integrations/base.rb +77 -6
  22. data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
  23. data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
  24. data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
  25. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
  26. data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
  27. data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
  28. data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
  29. data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
  30. data/lib/llm_cost_tracker/price_freshness.rb +3 -3
  31. data/lib/llm_cost_tracker/price_registry.rb +3 -0
  32. data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
  33. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  34. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
  35. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  36. data/lib/llm_cost_tracker/price_sync.rb +103 -111
  37. data/lib/llm_cost_tracker/prices.json +225 -229
  38. data/lib/llm_cost_tracker/pricing.rb +27 -15
  39. data/lib/llm_cost_tracker/report.rb +8 -1
  40. data/lib/llm_cost_tracker/report_data.rb +25 -9
  41. data/lib/llm_cost_tracker/retention.rb +30 -7
  42. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  43. data/lib/llm_cost_tracker/stream_capture.rb +7 -0
  44. data/lib/llm_cost_tracker/stream_collector.rb +25 -1
  45. data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
  46. data/lib/llm_cost_tracker/tracker.rb +7 -59
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +1 -0
  49. data/lib/tasks/llm_cost_tracker.rake +24 -78
  50. metadata +26 -15
  51. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  52. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  53. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  54. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
  55. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  56. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  57. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  58. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  59. data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
@@ -84,7 +84,8 @@ module LlmCostTracker
84
84
  return value if value.nil?
85
85
 
86
86
  string = value.to_s
87
- CSV_FORMULA_PREFIXES.include?(string[0]) ? "'#{string}" : string
87
+ stripped = string.lstrip
88
+ CSV_FORMULA_PREFIXES.include?(stripped[0]) ? "'#{string}" : string
88
89
  end
89
90
  end
90
91
  end
@@ -3,7 +3,9 @@
3
3
  module LlmCostTracker
4
4
  class DashboardController < ApplicationController
5
5
  def index
6
- @from_date, @to_date = overview_range
6
+ range = Dashboard::DateRange.call(params: params)
7
+ @from_date = range.from
8
+ @to_date = range.to
7
9
  prev_from, prev_to = previous_range
8
10
  filter_params = LlmCostTracker::ParameterHash.to_hash(params)
9
11
  scope = Dashboard::Filter.call(
@@ -23,25 +25,11 @@ module LlmCostTracker
23
25
 
24
26
  private
25
27
 
26
- def overview_range
27
- to_date = parsed_date(params[:to]) || Date.current
28
- from_date = parsed_date(params[:from]) || (to_date - 29)
29
- [from_date, to_date]
30
- end
31
-
32
28
  def previous_range
33
29
  span_days = (@to_date - @from_date).to_i + 1
34
30
  prev_to = @from_date - 1
35
31
  prev_from = prev_to - (span_days - 1)
36
32
  [prev_from, prev_to]
37
33
  end
38
-
39
- def parsed_date(value)
40
- return nil if value.to_s.strip.empty?
41
-
42
- Date.iso8601(value.to_s)
43
- rescue ArgumentError
44
- nil
45
- end
46
34
  end
47
35
  end
@@ -8,12 +8,13 @@ module LlmCostTracker
8
8
 
9
9
  def show
10
10
  @tag_key = params[:key]
11
- @rows = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
12
- @total_calls = @rows.sum(&:calls)
13
-
14
- tagged_rows = @rows.reject { |r| r.value == "(untagged)" }
15
- @tagged_calls = tagged_rows.sum(&:calls)
16
- @distinct_values = tagged_rows.size
11
+ breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
12
+ @rows = breakdown.rows
13
+ @total_calls = breakdown.total_calls
14
+ @tagged_calls = breakdown.tagged_calls
15
+ @distinct_values = breakdown.distinct_values
16
+ @tag_value_limit = breakdown.limit
17
+ @tag_values_limited = breakdown.limited?
17
18
  end
18
19
  end
19
20
  end
@@ -4,6 +4,9 @@ require "json"
4
4
 
5
5
  module LlmCostTracker
6
6
  module ApplicationHelper
7
+ TAG_VALUE_SUMMARY_BYTES = 80
8
+ TAG_TOOLTIP_BYTES = 512
9
+
7
10
  include DashboardFilterHelper
8
11
  include DashboardFilterOptionsHelper
9
12
  include DashboardQueryHelper
@@ -116,6 +119,10 @@ module LlmCostTracker
116
119
  visible
117
120
  end
118
121
 
122
+ def tag_chips_title(tags)
123
+ truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
124
+ end
125
+
119
126
  def budget_fill_modifier(percent)
120
127
  percent = percent.to_f
121
128
  return "lct-budget-fill--over" if percent >= 100.0
@@ -143,12 +150,20 @@ module LlmCostTracker
143
150
  end
144
151
 
145
152
  def tag_value_summary(value)
146
- case value
147
- when Hash, Array
148
- JSON.generate(value)
149
- else
150
- value.to_s
151
- end
153
+ string = case value
154
+ when Hash, Array
155
+ JSON.generate(value)
156
+ else
157
+ value.to_s
158
+ end
159
+
160
+ truncate_text(string, TAG_VALUE_SUMMARY_BYTES)
161
+ end
162
+
163
+ def truncate_text(string, limit)
164
+ return string if string.bytesize <= limit
165
+
166
+ "#{string.byteslice(0, limit).to_s.encode('UTF-8', invalid: :replace, undef: :replace)}..."
152
167
  end
153
168
  end
154
169
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module DashboardFilterOptionsHelper
5
+ MAX_FILTER_OPTIONS = 100
6
+
5
7
  def provider_filter_options(filter_params: params)
6
8
  filter_options_for(:provider, filter_params: filter_params)
7
9
  end
@@ -19,7 +21,7 @@ module LlmCostTracker
19
21
  )
20
22
  values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
21
23
  .where.not(column => [nil, ""])
22
- .distinct.order(column).pluck(column)
24
+ .distinct.order(column).limit(MAX_FILTER_OPTIONS).pluck(column)
23
25
  current = source[column.to_s].presence || source[column].presence
24
26
  values.unshift(current) if current && !values.include?(current)
25
27
  values
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ class DateRange
6
+ DEFAULT_DAYS = 30
7
+ MAX_DAYS = 366
8
+
9
+ attr_reader :from, :to
10
+
11
+ def self.call(params:, today: Date.current)
12
+ new(params: params, today: today)
13
+ end
14
+
15
+ def self.parse(params, key)
16
+ value = LlmCostTracker::ParameterHash.with_indifferent_access(params)[key].to_s.strip
17
+ return nil if value.empty?
18
+
19
+ Date.iso8601(value)
20
+ rescue ArgumentError
21
+ nil
22
+ end
23
+
24
+ def self.validate!(from:, to:)
25
+ return if from.nil? && to.nil?
26
+
27
+ raise InvalidFilterError, "from and to dates must be provided together" if from.nil? || to.nil?
28
+ raise InvalidFilterError, "from date must be on or before to date" if from > to
29
+ return if ((to - from).to_i + 1) <= MAX_DAYS
30
+
31
+ raise InvalidFilterError, "date range cannot exceed #{MAX_DAYS} days"
32
+ end
33
+
34
+ def initialize(params:, today:)
35
+ @to = self.class.parse(params, :to) || today
36
+ @from = self.class.parse(params, :from) || (@to - (DEFAULT_DAYS - 1))
37
+ self.class.validate!(from: @from, to: @to)
38
+ freeze
39
+ end
40
+ end
41
+ end
42
+ end
@@ -31,9 +31,12 @@ module LlmCostTracker
31
31
  attr_reader :scope, :params
32
32
 
33
33
  def apply_date_filters(relation)
34
- from = parse_date(:from)&.beginning_of_day
35
- to = parse_date(:to)&.end_of_day
34
+ from_date = parse_date(:from)
35
+ to_date = parse_date(:to)
36
+ Dashboard::DateRange.validate!(from: from_date, to: to_date)
36
37
 
38
+ from = from_date&.beginning_of_day
39
+ to = to_date&.end_of_day
37
40
  relation = relation.where(tracked_at: from..) if from
38
41
  relation = relation.where(tracked_at: ..to) if to
39
42
  relation
@@ -89,12 +92,7 @@ module LlmCostTracker
89
92
  end
90
93
 
91
94
  def parse_date(key)
92
- value = normalized_string(params[key])
93
- return nil if value.nil?
94
-
95
- Date.iso8601(value)
96
- rescue ArgumentError
97
- nil
95
+ Dashboard::DateRange.parse(params, key)
98
96
  end
99
97
 
100
98
  def normalized_string(value)
@@ -65,11 +65,12 @@ module LlmCostTracker
65
65
 
66
66
  scope
67
67
  .where(tracked_at: window)
68
- .pluck(:provider, :model, :tracked_at, :total_cost)
69
- .each do |provider, model, tracked_at, total_cost|
70
- next if total_cost.nil?
71
-
72
- grouped[[provider, model]][tracked_at.to_date] += total_cost.to_f
68
+ .where.not(total_cost: nil)
69
+ .group(:provider, :model)
70
+ .group_by_period(:day)
71
+ .sum(:total_cost)
72
+ .each do |(provider, model, day), total_cost|
73
+ grouped[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
73
74
  end
74
75
 
75
76
  grouped
@@ -9,28 +9,54 @@ module LlmCostTracker
9
9
  :average_cost_per_call
10
10
  )
11
11
 
12
+ TagBreakdownResult = Data.define(
13
+ :rows,
14
+ :total_calls,
15
+ :tagged_calls,
16
+ :distinct_values,
17
+ :limit
18
+ ) do
19
+ def limited? = distinct_values > rows.size
20
+ end
21
+
12
22
  class TagBreakdown
23
+ DEFAULT_LIMIT = 100
24
+
13
25
  class << self
14
- def call(key:, scope: LlmCostTracker::LlmApiCall.all)
15
- new(scope: scope, key: key).rows
26
+ def call(key:, scope: LlmCostTracker::LlmApiCall.all, limit: DEFAULT_LIMIT)
27
+ new(scope: scope, key: key, limit: limit).result
16
28
  end
17
29
  end
18
30
 
19
- def initialize(scope:, key:)
31
+ def initialize(scope:, key:, limit:)
20
32
  @scope = scope
21
33
  @key = LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
34
+ @limit = normalized_limit(limit)
35
+ @connection = LlmCostTracker::LlmApiCall.connection
22
36
  end
23
37
 
24
- def rows
25
- costs = scope.cost_by_tag(key)
26
- counts = counts_by_tag
38
+ def result
39
+ counts = summary_counts
27
40
 
28
- costs.map do |value, total_cost|
29
- calls = counts[value].to_i
30
- total_cost = total_cost.to_f
41
+ TagBreakdownResult.new(
42
+ rows: rows,
43
+ total_calls: counts.fetch(:total_calls),
44
+ tagged_calls: counts.fetch(:tagged_calls),
45
+ distinct_values: counts.fetch(:distinct_values),
46
+ limit: limit
47
+ )
48
+ end
31
49
 
50
+ private
51
+
52
+ attr_reader :scope, :key, :limit, :connection
53
+
54
+ def rows
55
+ connection.select_all(rows_sql).map do |row|
56
+ calls = row["calls_count"].to_i
57
+ total_cost = row["total_cost_sum"].to_f
32
58
  TagBreakdownRow.new(
33
- value: value,
59
+ value: LlmCostTracker::LlmApiCall.tag_value_label(row["tag_value"]),
34
60
  calls: calls,
35
61
  total_cost: total_cost,
36
62
  average_cost_per_call: calls.positive? ? total_cost / calls : 0.0
@@ -38,18 +64,48 @@ module LlmCostTracker
38
64
  end
39
65
  end
40
66
 
41
- private
67
+ def summary_counts
68
+ row = connection.select_one(summary_sql) || {}
69
+ {
70
+ total_calls: row["total_calls"].to_i,
71
+ tagged_calls: row["tagged_calls"].to_i,
72
+ distinct_values: row["distinct_values"].to_i
73
+ }
74
+ end
42
75
 
43
- attr_reader :scope, :key
76
+ def rows_sql
77
+ <<~SQL.squish
78
+ SELECT #{tag_expression} AS tag_value,
79
+ COUNT(*) AS calls_count,
80
+ COALESCE(SUM(sub.total_cost), 0) AS total_cost_sum
81
+ FROM (#{scope.to_sql}) AS sub
82
+ WHERE #{tag_present_predicate}
83
+ GROUP BY #{tag_expression}
84
+ ORDER BY total_cost_sum DESC, calls_count DESC, tag_value ASC
85
+ LIMIT #{limit}
86
+ SQL
87
+ end
44
88
 
45
- def counts_by_tag
46
- scope.group_by_tag(key).count.each_with_object(Hash.new(0)) do |(raw, count), hash|
47
- hash[label(raw)] += count.to_i
48
- end
89
+ def summary_sql
90
+ <<~SQL.squish
91
+ SELECT COUNT(*) AS total_calls,
92
+ COALESCE(SUM(CASE WHEN #{tag_present_predicate} THEN 1 ELSE 0 END), 0) AS tagged_calls,
93
+ COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_expression} END) AS distinct_values
94
+ FROM (#{scope.to_sql}) AS sub
95
+ SQL
96
+ end
97
+
98
+ def tag_present_predicate
99
+ "#{tag_expression} IS NOT NULL AND #{tag_expression} != ''"
100
+ end
101
+
102
+ def tag_expression
103
+ @tag_expression ||= LlmCostTracker::LlmApiCall.tag_value_expression(key, table_name: "sub")
49
104
  end
50
105
 
51
- def label(value)
52
- value.nil? || value == "" ? "(untagged)" : value.to_s
106
+ def normalized_limit(value)
107
+ value = value.to_i
108
+ value.positive? ? [value, DEFAULT_LIMIT].min : DEFAULT_LIMIT
53
109
  end
54
110
  end
55
111
  end
@@ -5,15 +5,18 @@ module LlmCostTracker
5
5
  TagKeyRow = Data.define(:key, :calls_count, :distinct_values)
6
6
 
7
7
  class TagKeyExplorer
8
+ DEFAULT_LIMIT = 100
9
+
8
10
  class << self
9
- def call(scope: LlmCostTracker::LlmApiCall.all)
10
- new(scope: scope).rows
11
+ def call(scope: LlmCostTracker::LlmApiCall.all, limit: DEFAULT_LIMIT)
12
+ new(scope: scope, limit: limit).rows
11
13
  end
12
14
  end
13
15
 
14
- def initialize(scope:)
16
+ def initialize(scope:, limit:)
15
17
  @scope = scope
16
18
  @connection = LlmCostTracker::LlmApiCall.connection
19
+ @limit = normalized_limit(limit)
17
20
  end
18
21
 
19
22
  def rows
@@ -32,7 +35,7 @@ module LlmCostTracker
32
35
 
33
36
  private
34
37
 
35
- attr_reader :scope, :connection
38
+ attr_reader :scope, :connection, :limit
36
39
 
37
40
  def subquery
38
41
  scope.to_sql
@@ -62,6 +65,7 @@ module LlmCostTracker
62
65
  AND sub.tags != ''
63
66
  GROUP BY jt.key
64
67
  ORDER BY calls_count DESC
68
+ LIMIT #{limit}
65
69
  SQL
66
70
  end
67
71
 
@@ -76,6 +80,7 @@ module LlmCostTracker
76
80
  AND sub.tags::jsonb <> '{}'::jsonb
77
81
  GROUP BY key
78
82
  ORDER BY calls_count DESC
83
+ LIMIT #{limit}
79
84
  SQL
80
85
  end
81
86
 
@@ -91,8 +96,14 @@ module LlmCostTracker
91
96
  AND sub.tags != ''
92
97
  GROUP BY je.key
93
98
  ORDER BY calls_count DESC
99
+ LIMIT #{limit}
94
100
  SQL
95
101
  end
102
+
103
+ def normalized_limit(value)
104
+ value = value.to_i
105
+ value.positive? ? [value, DEFAULT_LIMIT].min : DEFAULT_LIMIT
106
+ end
96
107
  end
97
108
  end
98
109
  end
@@ -2,7 +2,7 @@
2
2
  <% if entries.empty? %>
3
3
  <span class="lct-tag-empty">(untagged)</span>
4
4
  <% else %>
5
- <span class="lct-tag-chips" title="<%= safe_json(tags) %>">
5
+ <span class="lct-tag-chips" title="<%= tag_chips_title(tags) %>">
6
6
  <% entries.each do |entry| %>
7
7
  <% if entry[:more] %>
8
8
  <span class="lct-tag-chip lct-tag-chip-more">+<%= entry[:more] %></span>
@@ -50,6 +50,10 @@
50
50
  <span><strong><%= percent(coverage_percent(@tagged_calls, @total_calls)) %></strong> coverage</span>
51
51
  <span><strong><%= number(@distinct_values) %></strong> distinct values</span>
52
52
  </p>
53
+
54
+ <% if @tag_values_limited %>
55
+ <p class="lct-toolbar-note">Showing top <%= number(@tag_value_limit) %> values by spend.</p>
56
+ <% end %>
53
57
  </section>
54
58
 
55
59
  <% if @rows.empty? %>
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "tag_key"
4
5
  require_relative "value_helpers"
5
6
  require_relative "configuration/instrumentation"
6
7
 
@@ -8,31 +9,21 @@ module LlmCostTracker
8
9
  class Configuration
9
10
  include ConfigurationInstrumentation
10
11
 
11
- OPENAI_COMPATIBLE_PROVIDERS = {
12
- "openrouter.ai" => "openrouter",
13
- "api.deepseek.com" => "deepseek"
14
- }.freeze
12
+ OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
15
13
 
16
14
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
17
15
  STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
18
16
  STORAGE_BACKENDS = %i[log active_record custom].freeze
19
17
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
20
- SHARED_SCALAR_ATTRIBUTES = %i[
21
- enabled
22
- custom_storage
23
- on_budget_exceeded
24
- monthly_budget
25
- daily_budget
26
- per_call_budget
27
- log_level
28
- prices_file
29
- ].freeze
18
+ SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
19
+ log_level prices_file max_tag_count max_tag_value_bytesize].freeze
30
20
  SHARED_ENUM_ATTRIBUTES = {
31
21
  storage_backend: [STORAGE_BACKENDS, :log],
32
22
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
33
23
  storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
34
24
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
35
25
  }.freeze
26
+ DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
36
27
 
37
28
  attr_reader(
38
29
  *SHARED_SCALAR_ATTRIBUTES,
@@ -41,6 +32,7 @@ module LlmCostTracker
41
32
  :pricing_overrides,
42
33
  :instrumented_integrations,
43
34
  :report_tag_breakdowns,
35
+ :redacted_tag_keys,
44
36
  :storage_backend,
45
37
  :storage_error_behavior,
46
38
  :unknown_pricing_behavior,
@@ -61,9 +53,12 @@ module LlmCostTracker
61
53
  self.unknown_pricing_behavior = :warn
62
54
  @log_level = :info
63
55
  @prices_file = nil
64
- @pricing_overrides = {}
56
+ @max_tag_count = 50
57
+ @max_tag_value_bytesize = 1024
58
+ @pricing_overrides = {}
65
59
  @instrumented_integrations = []
66
60
  @report_tag_breakdowns = []
61
+ @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
67
62
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
68
63
  @finalized = false
69
64
  end
@@ -85,7 +80,12 @@ module LlmCostTracker
85
80
 
86
81
  def report_tag_breakdowns=(value)
87
82
  ensure_shared_configuration_mutable!
88
- @report_tag_breakdowns = value
83
+ @report_tag_breakdowns = normalize_report_tag_breakdowns(value)
84
+ end
85
+
86
+ def redacted_tag_keys=(value)
87
+ ensure_shared_configuration_mutable!
88
+ @redacted_tag_keys = Array(value).map(&:to_s)
89
89
  end
90
90
 
91
91
  SHARED_SCALAR_ATTRIBUTES.each do |name|
@@ -107,6 +107,7 @@ module LlmCostTracker
107
107
  @pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
108
108
  @instrumented_integrations = ValueHelpers.deep_freeze(@instrumented_integrations || [])
109
109
  @report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
110
+ @redacted_tag_keys = ValueHelpers.deep_freeze(Array(@redacted_tag_keys))
110
111
  @openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
111
112
  @finalized = true
112
113
  self
@@ -123,6 +124,7 @@ module LlmCostTracker
123
124
  ValueHelpers.deep_dup(@instrumented_integrations || [])
124
125
  )
125
126
  copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
127
+ copy.instance_variable_set(:@redacted_tag_keys, ValueHelpers.deep_dup(@redacted_tag_keys || []))
126
128
  copy.instance_variable_set(
127
129
  :@openai_compatible_providers,
128
130
  ValueHelpers.deep_dup(@openai_compatible_providers || {})
@@ -149,6 +151,10 @@ module LlmCostTracker
149
151
  end
150
152
  end
151
153
 
154
+ def normalize_report_tag_breakdowns(value)
155
+ Array(value).map { |key| TagKey.validate!(key, error_class: Error) }
156
+ end
157
+
152
158
  def ensure_shared_configuration_mutable!
153
159
  return unless finalized?
154
160
 
@@ -160,7 +160,7 @@ module LlmCostTracker
160
160
  def feature_generators(columns) = columns.map { |column| FEATURE_COLUMNS.fetch(column) }.uniq
161
161
 
162
162
  def builtin_prices_updated_at
163
- LlmCostTracker::Pricing.metadata.fetch("updated_at", "unknown")
163
+ LlmCostTracker::PriceRegistry.metadata.fetch("updated_at", "unknown")
164
164
  end
165
165
  end
166
166
  end
@@ -39,6 +39,7 @@ module LlmCostTracker
39
39
 
40
40
  add_engine_require
41
41
  route %(mount LlmCostTracker::Engine => "/llm-costs")
42
+ say "Mount /llm-costs behind your app's admin auth before deploying.", :yellow
42
43
  end
43
44
 
44
45
  private
@@ -11,10 +11,16 @@ LlmCostTracker.configure do |config|
11
11
  # Tags are merged into every event. Use a callable for request/job-time context.
12
12
  config.default_tags = -> { { environment: Rails.env } }
13
13
 
14
+ # Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
15
+ # config.max_tag_count = 50
16
+ # config.max_tag_value_bytesize = 1024
17
+ # config.redacted_tag_keys = %w[api_key access_token authorization credential password refresh_token secret]
18
+
14
19
  # Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
15
- # Enable only the SDKs your app already uses.
20
+ # Enabled integrations are checked at boot, so enable only clients your app loads.
16
21
  # config.instrument :openai
17
22
  # config.instrument :anthropic
23
+ # config.instrument :ruby_llm
18
24
 
19
25
  # Budget behavior: :notify calls on_budget_exceeded, :raise raises after recording,
20
26
  # :block_requests preflights monthly/daily budgets before supported requests.
@@ -33,7 +39,7 @@ LlmCostTracker.configure do |config|
33
39
  <% if options[:prices] -%>
34
40
 
35
41
  # Local JSON/YAML pricing file generated by --prices. Keep it in source control
36
- # and refresh it with bin/rails llm_cost_tracker:prices:sync.
42
+ # and refresh it with bin/rails llm_cost_tracker:prices:refresh.
37
43
  config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
38
44
  <% end -%>
39
45
 
@@ -10,10 +10,19 @@ module LlmCostTracker
10
10
  class << self
11
11
  def integration_name = :anthropic
12
12
 
13
- def target_patches
13
+ def minimum_version = "1.36.0"
14
+
15
+ def version_constant = "Anthropic::VERSION"
16
+
17
+ def patch_targets
14
18
  [
15
- [constant("Anthropic::Resources::Messages"), MessagesPatch],
16
- [constant("Anthropic::Resources::Beta::Messages"), MessagesPatch]
19
+ patch_target("Anthropic::Resources::Messages", with: MessagesPatch, methods: :create),
20
+ patch_target(
21
+ "Anthropic::Resources::Beta::Messages",
22
+ with: MessagesPatch,
23
+ methods: :create,
24
+ optional: true
25
+ )
17
26
  ]
18
27
  end
19
28