llm_cost_tracker 0.13.0 → 0.14.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
@@ -5,20 +5,23 @@ require "date"
5
5
  module LlmCostTracker
6
6
  module Dashboard
7
7
  class Filter
8
+ MAX_TAG_FILTERS = 10
9
+ MATCH_NOTHING = Arel.sql("1 = 0")
8
10
  STREAM_FILTER_OPTIONS = [
9
11
  ["Streaming only", "yes"],
10
12
  ["Non-streaming only", "no"]
11
13
  ].freeze
12
14
 
13
15
  class << self
14
- def call(scope: LlmCostTracker::Call.all, params: {})
15
- new(scope: scope, params: params).relation
16
+ def call(scope: LlmCostTracker::Call.all, params: {}, tags: {})
17
+ new(scope: scope, params: params, tags: tags).relation
16
18
  end
17
19
  end
18
20
 
19
- def initialize(scope:, params:)
21
+ def initialize(scope:, params:, tags: {})
20
22
  @scope = scope
21
23
  @params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
24
+ @extra_tags = tags
22
25
  end
23
26
 
24
27
  def relation
@@ -27,13 +30,13 @@ module LlmCostTracker
27
30
  filtered_scope = apply_exact_filter(filtered_scope, :provider)
28
31
  filtered_scope = apply_exact_filter(filtered_scope, :model)
29
32
  filtered_scope = apply_stream_filter(filtered_scope)
30
- filtered_scope = apply_usage_source_filter(filtered_scope)
33
+ filtered_scope = apply_exact_filter(filtered_scope, :usage_source)
31
34
  apply_tag_filters(filtered_scope)
32
35
  end
33
36
 
34
37
  private
35
38
 
36
- attr_reader :scope, :params
39
+ attr_reader :scope, :params, :extra_tags
37
40
 
38
41
  def apply_date_filters(relation)
39
42
  from_date = Dashboard::DateRange.parse(params, :from)
@@ -50,21 +53,25 @@ module LlmCostTracker
50
53
  end
51
54
 
52
55
  def apply_exact_filter(relation, key)
53
- value = normalized_string(params[key])
56
+ value = normalized_string(params[key], key)
54
57
  return relation if value.nil?
55
58
 
56
- relation.where(key => value)
59
+ matchable?([value]) ? relation.where(key => value) : relation.where(MATCH_NOTHING)
57
60
  end
58
61
 
59
62
  def apply_tag_filters(relation)
60
- tags = tag_params
63
+ tags = tag_params.merge(extra_tags)
61
64
  return relation if tags.empty?
65
+ if tags.size > MAX_TAG_FILTERS
66
+ raise LlmCostTracker::InvalidFilterError,
67
+ "at most #{MAX_TAG_FILTERS} tag filters are allowed, got #{tags.size}"
68
+ end
62
69
 
63
- relation.by_tags(tags)
70
+ matchable?(tags.values) ? relation.by_tags(tags) : relation.where(MATCH_NOTHING)
64
71
  end
65
72
 
66
73
  def apply_stream_filter(relation)
67
- value = normalized_string(params[:stream])
74
+ value = normalized_string(params[:stream], :stream)
68
75
  return relation if value.nil?
69
76
 
70
77
  case value.downcase
@@ -74,28 +81,24 @@ module LlmCostTracker
74
81
  end
75
82
  end
76
83
 
77
- def apply_usage_source_filter(relation)
78
- value = normalized_string(params[:usage_source])
79
- return relation if value.nil?
80
-
81
- relation.where(usage_source: value)
82
- end
83
-
84
84
  def tag_params
85
85
  tags = LlmCostTracker::Dashboard::Params.to_hash(params[:tag])
86
86
 
87
87
  tags.each_with_object({}) do |(key, value), normalized|
88
- value = normalized_string(value)
88
+ value = normalized_string(value, "tag filter value")
89
89
  next if value.nil?
90
90
 
91
91
  normalized[LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
92
92
  end
93
93
  end
94
94
 
95
- def normalized_string(value)
96
- return nil if value.nil?
95
+ def matchable?(values)
96
+ values.none? { |value| value.include?("\0") } ||
97
+ !LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
98
+ end
97
99
 
98
- value.to_s.strip.presence
100
+ def normalized_string(value, name)
101
+ LlmCostTracker::Dashboard::Params.scalar(value, name).strip.presence
99
102
  end
100
103
  end
101
104
  end
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  module_function
7
7
 
8
8
  def status
9
- budget = LlmCostTracker.configuration.monthly_budget
9
+ budget = LlmCostTracker.configuration.budgets.monthly
10
10
  return nil unless budget
11
11
 
12
12
  budget = budget.to_f
@@ -6,6 +6,7 @@ module LlmCostTracker
6
6
  DEFAULT_PER = 50
7
7
  MAX_PER = 200
8
8
  MIN_PAGE = 1
9
+ MAX_PAGE = 100_000
9
10
  MIN_PER = 1
10
11
 
11
12
  attr_reader :page, :per
@@ -13,7 +14,7 @@ module LlmCostTracker
13
14
  def self.call(params)
14
15
  params = Params.to_hash(params).symbolize_keys
15
16
  new(
16
- page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
17
+ page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE, max: MAX_PAGE),
17
18
  per: integer_param(params, :per, default: DEFAULT_PER, min: MIN_PER, max: MAX_PER)
18
19
  )
19
20
  end
@@ -3,6 +3,8 @@
3
3
  module LlmCostTracker
4
4
  module Dashboard
5
5
  module Params
6
+ QUERY_KEYS = %i[from to provider model tag tag_value stream usage_source cost_status sort dir page per].freeze
7
+
6
8
  class << self
7
9
  def to_hash(value)
8
10
  return {} if value.nil?
@@ -17,6 +19,14 @@ module LlmCostTracker
17
19
  {}
18
20
  end
19
21
 
22
+ def scalar(value, name)
23
+ if value.is_a?(Hash) || value.is_a?(Array) || value.respond_to?(:to_unsafe_h)
24
+ raise InvalidFilterError, "#{name} must be a single value"
25
+ end
26
+
27
+ value.to_s
28
+ end
29
+
20
30
  def tag_query(value)
21
31
  to_hash(value).each_with_object({}) do |(key, tag_value), tags|
22
32
  key = key.to_s
@@ -46,8 +46,8 @@ module LlmCostTracker
46
46
 
47
47
  def subtitle_for(key)
48
48
  case key
49
- when :overrides then "config.pricing_overrides"
50
- when :file then LlmCostTracker.configuration.prices_file.to_s
49
+ when :overrides then "config.pricing.overrides"
50
+ when :file then LlmCostTracker.configuration.pricing.file.to_s
51
51
  when :bundled then "ships with the gem"
52
52
  end
53
53
  end
@@ -55,7 +55,7 @@ module LlmCostTracker
55
55
  def updated_at_for(key)
56
56
  case key
57
57
  when :file
58
- path = LlmCostTracker.configuration.prices_file
58
+ path = LlmCostTracker.configuration.pricing.file
59
59
  Pricing::Registry.file_metadata(path)["updated_at"] || Pricing::Registry.prices_file_mtime_iso
60
60
  when :bundled
61
61
  Pricing::Registry.metadata["updated_at"]
@@ -7,13 +7,15 @@ module LlmCostTracker
7
7
 
8
8
  class << self
9
9
  def current
10
- return @current if defined?(@current)
10
+ return if @healthy
11
11
 
12
- @current = compute
12
+ state = compute
13
+ @healthy = state.nil?
14
+ state
13
15
  end
14
16
 
15
17
  def reset!
16
- remove_instance_variable(:@current) if defined?(@current)
18
+ @healthy = false
17
19
  end
18
20
 
19
21
  private
@@ -2,7 +2,7 @@
2
2
  <% line_item_costs_by_component = call_line_item_costs_by_component(@call) %>
3
3
  <% token_segments = priced_components.map do |component|
4
4
  token_key = component.fetch(:token_key)
5
- value = @call.has_attribute?(token_key) ? @call[token_key] : 0
5
+ value = @call[token_key]
6
6
  { label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
7
7
  end %>
8
8
  <% cost_segments = [] %>
@@ -68,13 +68,11 @@ end %>
68
68
  <dt>Batch</dt>
69
69
  <dd><%= @call.batch? ? "yes" : "no" %></dd>
70
70
  </div>
71
- <% if @call.has_attribute?(:stream) %>
72
- <div class="lct-meta-strip-item">
73
- <dt>Stream</dt>
74
- <dd><%= @call.stream? ? "yes" : "no" %></dd>
75
- </div>
76
- <% end %>
77
- <% if @call.has_attribute?(:usage_source) && @call.usage_source.present? %>
71
+ <div class="lct-meta-strip-item">
72
+ <dt>Stream</dt>
73
+ <dd><%= @call.stream? ? "yes" : "no" %></dd>
74
+ </div>
75
+ <% if @call.usage_source.present? %>
78
76
  <div class="lct-meta-strip-item">
79
77
  <dt>Usage source</dt>
80
78
  <dd><code class="lct-code-id"><%= @call.usage_source %></code></dd>
@@ -21,6 +21,17 @@
21
21
  </div>
22
22
  <% end %>
23
23
 
24
+ <% if @unseen_budget_tags %>
25
+ <h3 class="lct-stat-section-label">Budgets</h3>
26
+ <div class="lct-stat-grid">
27
+ <div class="lct-stat lct-stat-warn">
28
+ <div class="lct-stat-head"><p class="lct-stat-label">Budgeted tags never recorded</p></div>
29
+ <p class="lct-stat-value"><%= @unseen_budget_tags.keys.join(", ") %></p>
30
+ <p class="lct-stat-foot">No call carries <%= @unseen_budget_tags.keys.one? ? "this tag" : "these tags" %> yet, so <%= @unseen_budget_tags.keys.one? ? "its budget never fires" : "their budgets never fire" %>. Expected on a young ledger; otherwise check the name against the tags you pass.</p>
31
+ </div>
32
+ </div>
33
+ <% end %>
34
+
24
35
  <% if @summary.total.zero? %>
25
36
  <section class="lct-panel lct-empty">
26
37
  <h2 class="lct-state-title">No data yet</h2>
@@ -9,9 +9,7 @@
9
9
  </summary>
10
10
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
11
11
  <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
12
- <% current_query.except(:from, :to, :page, :per, *extra_except).each do |key, value| %>
13
- <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
14
- <% end %>
12
+ <%= hidden_query_fields(current_query.except(:from, :to, :page, :per, *extra_except)) %>
15
13
  <div class="lct-filter-pop-field"><label for="lct-filter-from">From</label><input type="date" name="from" id="lct-filter-from" value="<%= @from_date.iso8601 %>"></div>
16
14
  <div class="lct-filter-pop-field"><label for="lct-filter-to">To</label><input type="date" name="to" id="lct-filter-to" value="<%= @to_date.iso8601 %>"></div>
17
15
  <button type="submit" class="lct-button lct-button-primary">Apply</button>
@@ -10,9 +10,7 @@
10
10
  </summary>
11
11
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
12
  <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
- <% current_query.except(:model, :page, :per, *extra_except).each do |key, value| %>
14
- <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
15
- <% end %>
13
+ <%= hidden_query_fields(current_query.except(:model, :page, :per, *extra_except)) %>
16
14
  <div class="lct-filter-pop-field">
17
15
  <label for="lct-filter-model">Model</label>
18
16
  <%= select_tag :model, options_for_select(filter_options_for(:model), active), include_blank: "All models", id: "lct-filter-model" %>
@@ -10,9 +10,7 @@
10
10
  </summary>
11
11
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
12
  <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
- <% current_query.except(:provider, :page, :per, *extra_except).each do |key, value| %>
14
- <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
15
- <% end %>
13
+ <%= hidden_query_fields(current_query.except(:provider, :page, :per, *extra_except)) %>
16
14
  <div class="lct-filter-pop-field">
17
15
  <label for="lct-filter-provider">Provider</label>
18
16
  <%= select_tag :provider, options_for_select(filter_options_for(:provider), active), include_blank: "All providers", id: "lct-filter-provider" %>
@@ -11,9 +11,7 @@
11
11
  </summary>
12
12
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
13
13
  <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
14
- <% current_query.except(:stream, :page, :per, *extra_except).each do |key, value| %>
15
- <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
16
- <% end %>
14
+ <%= hidden_query_fields(current_query.except(:stream, :page, :per, *extra_except)) %>
17
15
  <div class="lct-filter-pop-field">
18
16
  <label for="lct-filter-stream">Stream</label>
19
17
  <%= select_tag :stream, options_for_select(LlmCostTracker::Dashboard::Filter::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
@@ -107,6 +107,7 @@
107
107
  </tr>
108
108
  </thead>
109
109
  <tbody>
110
+ <% drilldown_allowed = tag_drilldown_allowed?(params[:key]) %>
110
111
  <% @breakdown.rows.each do |row| %>
111
112
  <tr>
112
113
  <td><code class="lct-code-id"><%= row.value %></code></td>
@@ -117,6 +118,8 @@
117
118
  <td class="lct-num">
118
119
  <% if row.value == "(untagged)" %>
119
120
  <span class="lct-num-muted">n/a</span>
121
+ <% elsif !drilldown_allowed %>
122
+ <span class="lct-num-muted">tag filter limit</span>
120
123
  <% else %>
121
124
  <%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-page-link" %>
122
125
  <%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-page-link" %>
data/config/routes.rb CHANGED
@@ -4,7 +4,12 @@ LlmCostTracker::Engine.routes.draw do
4
4
  root "dashboard#index"
5
5
  resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
6
6
  resources :models, only: :index
7
- resources :tags, only: %i[index show], param: :key, format: false
7
+ resources :tags,
8
+ only: %i[index show],
9
+ param: :key,
10
+ format: false,
11
+ constraints: { key: %r{[^/]+} },
12
+ defaults: { format: :html }
8
13
  get "data_quality", to: "data_quality#index", as: :data_quality
9
14
  get "pricing", to: "pricing#index", as: :pricing
10
15
 
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ledger/isolation"
4
+ require_relative "../ledger/schema/adapter"
5
+ require_relative "../ledger/tags/encoding"
6
+
7
+ module LlmCostTracker
8
+ module Budget
9
+ module PerTag
10
+ COST_COLUMN = "total_cost"
11
+ TIME_COLUMN = "tracked_at"
12
+ WINDOW_STARTS = { daily: :beginning_of_day, weekly: :beginning_of_week, monthly: :beginning_of_month }.freeze
13
+ WINDOW_NEXTS = { daily: :next_day, weekly: :next_week, monthly: :next_month }.freeze
14
+ SLOW_READ_SECONDS = 0.1
15
+ DEFAULT_BACKFILL_BATCH = 5_000
16
+
17
+ Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
18
+
19
+ class << self
20
+ def configured
21
+ LlmCostTracker.configuration.budgets.per_tag
22
+ end
23
+
24
+ def active?
25
+ return false if configured.empty?
26
+ return true if columns?
27
+
28
+ warn_missing_columns
29
+ false
30
+ end
31
+
32
+ def blocking?
33
+ active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
34
+ end
35
+
36
+ def rules_for(tags, blocking_only: false)
37
+ return [] unless active?
38
+
39
+ normalized = (tags || {}).to_h.transform_keys(&:to_s)
40
+ configured.filter_map do |key, entry|
41
+ raw = normalized[key]
42
+ next if raw.nil?
43
+ next if blocking_only && behavior_for(entry) != :block_requests
44
+
45
+ Rule.new(
46
+ key: key,
47
+ value: Ledger::Tags::Encoding.encode(raw),
48
+ windows: entry[:windows],
49
+ behavior: behavior_for(entry),
50
+ on_exceeded: on_exceeded_for(entry)
51
+ )
52
+ end
53
+ end
54
+
55
+ def rules_for_events(events)
56
+ events.each_with_object({}) do |event, grouped|
57
+ rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
58
+ end
59
+ end
60
+
61
+ def spend(key, value, window, time:)
62
+ spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
63
+ end
64
+
65
+ def spend_by_value(key, values, window, bucket)
66
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
68
+ LlmCostTracker::CallTag
69
+ .where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
70
+ .group(:value).sum(COST_COLUMN)
71
+ end
72
+ warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
73
+ totals
74
+ end
75
+
76
+ def window_start(window, time)
77
+ time.to_time.utc.public_send(WINDOW_STARTS.fetch(window))
78
+ end
79
+
80
+ def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
81
+ return 0 unless columns?
82
+
83
+ filled = 0
84
+ loop do
85
+ copied = copy_next_batch(batch_size)
86
+ break if copied.zero?
87
+
88
+ filled += copied
89
+ end
90
+ filled
91
+ end
92
+
93
+ def columns?
94
+ LlmCostTracker::CallTag.table_exists? &&
95
+ (LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
96
+ end
97
+
98
+ private
99
+
100
+ def window_range(window, bucket)
101
+ bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
102
+ end
103
+
104
+ def copy_next_batch(batch_size)
105
+ ids = LlmCostTracker::CallTag.where(TIME_COLUMN => nil).limit(batch_size).pluck(:id)
106
+ return 0 if ids.empty?
107
+
108
+ LlmCostTracker::CallTag.connection.update(copy_sql(ids))
109
+ end
110
+
111
+ def copy_sql(ids)
112
+ tags = LlmCostTracker::CallTag.quoted_table_name
113
+ calls = LlmCostTracker::Call.quoted_table_name
114
+ list = ids.join(",")
115
+ return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.connection)
116
+ UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
117
+ SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
118
+ WHERE t.id IN (#{list})
119
+ SQL
120
+
121
+ <<~SQL.squish
122
+ UPDATE #{tags} AS t
123
+ SET #{COST_COLUMN} = c.#{COST_COLUMN}, #{TIME_COLUMN} = c.#{TIME_COLUMN}
124
+ FROM #{calls} AS c
125
+ WHERE c.id = t.llm_cost_tracker_call_id AND t.id IN (#{list})
126
+ SQL
127
+ end
128
+
129
+ def behavior_for(entry)
130
+ entry[:behavior] || LlmCostTracker.configuration.budgets.exceeded_behavior
131
+ end
132
+
133
+ def on_exceeded_for(entry)
134
+ entry[:on_exceeded] || LlmCostTracker.configuration.budgets.on_exceeded
135
+ end
136
+
137
+ def warn_slow_read(key, window, seconds)
138
+ return if seconds < SLOW_READ_SECONDS
139
+
140
+ @slow_keys ||= Set.new
141
+ return unless @slow_keys.add?(key)
142
+
143
+ Logging.warn(
144
+ "config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. " \
145
+ "A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
146
+ "an index effectively. Budget high-cardinality tags such as a tenant or user id."
147
+ )
148
+ end
149
+
150
+ def warn_missing_columns
151
+ return if @missing_columns_warned
152
+
153
+ @missing_columns_warned = true
154
+ Logging.warn(
155
+ "config.budgets.per_tag is set but llm_cost_tracker_call_tags is missing " \
156
+ "#{COST_COLUMN} / #{TIME_COLUMN}; per-tag budgets are not enforced. Run the " \
157
+ "upgrade_per_tag_budgets generator and migrate, or clear config.budgets.per_tag."
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end