llm_cost_tracker 0.2.0.alpha2 → 0.2.0

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +4 -3
  4. data/app/assets/llm_cost_tracker/application.css +760 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +13 -0
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  10. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +42 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  14. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  15. data/app/services/llm_cost_tracker/dashboard/filter.rb +0 -3
  16. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  20. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +106 -74
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +201 -111
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +178 -78
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  30. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  31. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  32. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  33. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  34. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  35. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  37. data/config/routes.rb +3 -0
  38. data/lib/llm_cost_tracker/assets.rb +24 -0
  39. data/lib/llm_cost_tracker/engine.rb +2 -0
  40. data/lib/llm_cost_tracker/llm_api_call.rb +1 -1
  41. data/lib/llm_cost_tracker/price_registry.rb +17 -6
  42. data/lib/llm_cost_tracker/pricing.rb +19 -6
  43. data/lib/llm_cost_tracker/retention.rb +34 -0
  44. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  45. data/lib/llm_cost_tracker/tags_column.rb +13 -1
  46. data/lib/llm_cost_tracker/version.rb +1 -1
  47. data/lib/llm_cost_tracker.rb +1 -0
  48. data/lib/tasks/llm_cost_tracker.rake +8 -0
  49. data/llm_cost_tracker.gemspec +1 -2
  50. metadata +17 -5
  51. data/PLAN_0.2.md +0 -488
@@ -14,17 +14,11 @@ module LlmCostTracker
14
14
  private
15
15
 
16
16
  def ensure_llm_api_calls_table
17
- return if llm_api_calls_table_available?
17
+ return if LlmCostTracker::LlmApiCall.table_exists?
18
18
 
19
19
  render template: "llm_cost_tracker/shared/setup_required"
20
20
  end
21
21
 
22
- def llm_api_calls_table_available?
23
- LlmCostTracker::LlmApiCall.table_exists?
24
- rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid
25
- false
26
- end
27
-
28
22
  def render_database_error(error)
29
23
  @error = error
30
24
  render "llm_cost_tracker/errors/database", status: :internal_server_error
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class AssetsController < ActionController::Base
5
+ skip_forgery_protection if respond_to?(:skip_forgery_protection)
6
+
7
+ def stylesheet
8
+ path = File.join(LlmCostTracker::Assets.root, LlmCostTracker::Assets::STYLESHEET)
9
+ response.set_header("Cache-Control", "public, max-age=31536000, immutable")
10
+ send_file path, type: "text/css", disposition: "inline"
11
+ end
12
+ end
13
+ end
@@ -4,15 +4,8 @@ require "csv"
4
4
 
5
5
  module LlmCostTracker
6
6
  class CallsController < ApplicationController
7
- SORT_ORDERS = {
8
- "expensive" => "total_cost DESC NULLS LAST, tracked_at DESC",
9
- "input" => "input_tokens DESC, tracked_at DESC",
10
- "output" => "output_tokens DESC, tracked_at DESC",
11
- "slow" => "latency_ms DESC NULLS LAST, tracked_at DESC",
12
- "unknown_pricing" => "tracked_at DESC, id DESC"
13
- }.freeze
14
-
15
7
  CSV_EXPORT_LIMIT = 10_000
8
+ CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
16
9
 
17
10
  def index
18
11
  @sort = params[:sort].to_s
@@ -46,7 +39,24 @@ module LlmCostTracker
46
39
  private
47
40
 
48
41
  def calls_order(sort)
49
- SORT_ORDERS[sort] || "tracked_at DESC, id DESC"
42
+ case sort
43
+ when "expensive"
44
+ "CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{default_order}"
45
+ when "input"
46
+ "input_tokens DESC, #{default_order}"
47
+ when "output"
48
+ "output_tokens DESC, #{default_order}"
49
+ when "slow"
50
+ return default_order unless LlmApiCall.latency_column?
51
+
52
+ "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{default_order}"
53
+ else
54
+ default_order
55
+ end
56
+ end
57
+
58
+ def default_order
59
+ "tracked_at DESC, id DESC"
50
60
  end
51
61
 
52
62
  def render_csv(relation)
@@ -60,18 +70,25 @@ module LlmCostTracker
60
70
  relation.each do |call|
61
71
  row = [
62
72
  call.tracked_at&.utc&.iso8601,
63
- call.provider,
64
- call.model,
73
+ csv_safe(call.provider),
74
+ csv_safe(call.model),
65
75
  call.input_tokens,
66
76
  call.output_tokens,
67
77
  call.total_tokens,
68
78
  call.total_cost
69
79
  ]
70
80
  row << call.latency_ms if latency
71
- row << call.parsed_tags.to_json
81
+ row << csv_safe(call.parsed_tags.to_json)
72
82
  csv << row
73
83
  end
74
84
  end
75
85
  end
86
+
87
+ def csv_safe(value)
88
+ return value if value.nil?
89
+
90
+ string = value.to_s
91
+ CSV_FORMULA_PREFIXES.include?(string[0]) ? "'#{string}" : string
92
+ end
76
93
  end
77
94
  end
@@ -4,12 +4,16 @@ module LlmCostTracker
4
4
  class DashboardController < ApplicationController
5
5
  def index
6
6
  @from_date, @to_date = overview_range
7
+ prev_from, prev_to = previous_range
7
8
  scope = Dashboard::Filter.call(params: overview_filter_params)
8
9
  previous_scope = Dashboard::Filter.call(params: previous_filter_params)
10
+ model_rows = Dashboard::TopModels.call(scope: scope, limit: 10)
9
11
 
10
12
  @stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
11
13
  @time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
12
- @top_models = Dashboard::TopModels.call(scope: scope, limit: 5)
14
+ @comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
15
+ @spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
16
+ @top_models = model_rows.first(5)
13
17
  @providers = Dashboard::ProviderBreakdown.call(scope: scope)
14
18
  end
15
19
 
@@ -4,6 +4,12 @@ require "json"
4
4
 
5
5
  module LlmCostTracker
6
6
  module ApplicationHelper
7
+ include DashboardFilterHelper
8
+ include DashboardFilterOptionsHelper
9
+ include DashboardQueryHelper
10
+ include ChartHelper
11
+ include PaginationHelper
12
+
7
13
  def coverage_percent(numerator, denominator)
8
14
  return 0.0 unless denominator.to_i.positive?
9
15
 
@@ -46,19 +52,19 @@ module LlmCostTracker
46
52
  end
47
53
 
48
54
  def delta_badge(delta_percent, mode: :cost)
49
- return { text: "vs. prior: n/a", css_class: "lct-delta lct-delta-neutral" } if delta_percent.nil?
55
+ return { text: "n/a vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if delta_percent.nil?
50
56
 
51
57
  rounded = delta_percent.round(1)
52
- return { text: "= vs. prior", css_class: "lct-delta lct-delta-neutral" } if rounded.zero?
58
+ return { text: "0.0% vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if rounded.zero?
53
59
 
54
60
  sign = rounded.positive? ? "+" : ""
55
61
  text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
56
62
  css_class = if mode == :neutral
57
- "lct-delta lct-delta-neutral"
63
+ "lct-delta-badge lct-delta-neutral"
58
64
  elsif rounded.positive?
59
- "lct-delta lct-delta-up"
65
+ "lct-delta-badge lct-delta-up"
60
66
  else
61
- "lct-delta lct-delta-down"
67
+ "lct-delta-badge lct-delta-down"
62
68
  end
63
69
 
64
70
  { text: text, css_class: css_class }
@@ -71,6 +77,18 @@ module LlmCostTracker
71
77
  "#{[(value.to_f / max) * 100.0, 100.0].min.round(2)}%"
72
78
  end
73
79
 
80
+ def stack_segments(entries)
81
+ total = entries.sum { |entry| entry[:value].to_f }
82
+ return [] unless total.positive?
83
+
84
+ entries.filter_map do |entry|
85
+ value = entry[:value].to_f
86
+ next unless value.positive?
87
+
88
+ entry.merge(percent: (value / total) * 100.0)
89
+ end
90
+ end
91
+
74
92
  def safe_json(value)
75
93
  parsed = value.is_a?(String) ? JSON.parse(value) : value
76
94
  JSON.pretty_generate(parsed || {})
@@ -87,10 +105,33 @@ module LlmCostTracker
87
105
  summary.join(", ")
88
106
  end
89
107
 
108
+ def tag_chip_entries(tags, limit: 3)
109
+ normalized = normalized_tags(tags)
110
+ return [] if normalized.empty?
111
+
112
+ visible = normalized.first(limit).map do |key, value|
113
+ { key: key.to_s, value: tag_value_summary(value) }
114
+ end
115
+ visible << { more: normalized.size - limit } if normalized.size > limit
116
+ visible
117
+ end
118
+
119
+ def budget_fill_modifier(percent)
120
+ percent = percent.to_f
121
+ return "lct-budget-fill--over" if percent >= 100.0
122
+ return "lct-budget-fill--warn" if percent >= 80.0
123
+
124
+ ""
125
+ end
126
+
90
127
  def current_query(overrides = {})
91
128
  request.query_parameters.symbolize_keys.merge(overrides)
92
129
  end
93
130
 
131
+ def calls_query_for_model(provider:, model:)
132
+ current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
133
+ end
134
+
94
135
  private
95
136
 
96
137
  def normalized_tags(tags)
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module ChartHelper
5
+ def spend_chart_svg(points, comparison_points: nil, height: 180, y_ticks: 3)
6
+ return nil if points.blank?
7
+
8
+ cfg = chart_config(points, comparison_points, height, y_ticks)
9
+ parts = [chart_svg_open(cfg)]
10
+ parts.concat(chart_grid_and_axis(cfg))
11
+ parts << chart_paths(cfg)
12
+ parts.concat(chart_dots(cfg))
13
+ parts.concat(chart_x_labels(cfg))
14
+ parts << "</svg>"
15
+ parts.join.html_safe
16
+ end
17
+
18
+ private
19
+
20
+ def chart_fmt(value)
21
+ format("%.2f", value)
22
+ end
23
+
24
+ def chart_config(points, comparison_points, height, y_ticks)
25
+ width = 720
26
+ pad = { top: 16, right: 16, bottom: 28, left: 56 }
27
+ plot_w = width - pad[:left] - pad[:right]
28
+ plot_h = height - pad[:top] - pad[:bottom]
29
+ all_costs = points.map { |p| p[:cost].to_f } + Array(comparison_points).map { |p| p[:cost].to_f }
30
+ max_cost = [all_costs.max.to_f, 0.0001].max
31
+ coords = chart_coords(points, pad, plot_w, plot_h, max_cost)
32
+ comparison_coords = chart_coords(comparison_points, pad, plot_w, plot_h, max_cost) if comparison_points.present?
33
+
34
+ { width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
35
+ max_cost: max_cost, n: points.size, y_ticks: y_ticks, points: points, coords: coords,
36
+ comparison_points: comparison_points, comparison_coords: comparison_coords }
37
+ end
38
+
39
+ def chart_coords(points, pad, plot_w, plot_h, max_cost)
40
+ n = points.size
41
+ step = n > 1 ? plot_w.to_f / (n - 1) : 0.0
42
+
43
+ points.each_with_index.map do |point, idx|
44
+ x = pad[:left] + (idx * step)
45
+ y = pad[:top] + plot_h - ((point[:cost].to_f / max_cost) * plot_h)
46
+ [x, y]
47
+ end
48
+ end
49
+
50
+ def chart_svg_open(cfg)
51
+ attrs = [
52
+ %(class="lct-chart"),
53
+ %(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
54
+ %(preserveAspectRatio="none"),
55
+ %(role="img"),
56
+ %(aria-label="Daily spend trend")
57
+ ].join(" ")
58
+ "<svg #{attrs}>"
59
+ end
60
+
61
+ def chart_grid_and_axis(cfg)
62
+ (0..cfg[:y_ticks]).map { |i| chart_tick_line(cfg, i) }
63
+ end
64
+
65
+ def chart_tick_line(cfg, idx)
66
+ pad = cfg[:pad]
67
+ right_x = chart_fmt(pad[:left] + cfg[:plot_w])
68
+ left_x = chart_fmt(pad[:left])
69
+ text_x = chart_fmt(pad[:left] - 8)
70
+ value = cfg[:max_cost] * (cfg[:y_ticks] - idx).to_f / cfg[:y_ticks]
71
+ y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]))
72
+ label_y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]) + 3)
73
+ grid = %(<line class="lct-chart-grid" x1="#{left_x}" x2="#{right_x}" y1="#{y}" y2="#{y}"/>)
74
+ label = format("%.2f", value)
75
+ text = %(<text class="lct-chart-axis" x="#{text_x}" y="#{label_y}" text-anchor="end">$#{label}</text>)
76
+ "#{grid}#{text}"
77
+ end
78
+
79
+ def chart_paths(cfg)
80
+ line = build_line_path(cfg[:coords])
81
+ base_y = cfg[:pad][:top] + cfg[:plot_h]
82
+ area = build_area_path(cfg[:coords], cfg, base_y, line)
83
+ secondary = if cfg[:comparison_coords].present?
84
+ %(<path class="lct-chart-line-secondary" d="#{build_line_path(cfg[:comparison_coords])}"/>)
85
+ else
86
+ ""
87
+ end
88
+ %(<path class="lct-chart-area" d="#{area}"/>#{secondary}<path class="lct-chart-line" d="#{line}"/>)
89
+ end
90
+
91
+ def build_line_path(coords)
92
+ coords.each_with_index.map do |(x, y), idx|
93
+ "#{idx.zero? ? 'M' : 'L'}#{chart_fmt(x)},#{chart_fmt(y)}"
94
+ end.join(" ")
95
+ end
96
+
97
+ def build_area_path(coords, cfg, base_y, line)
98
+ if coords.size == 1
99
+ x, y = coords.first
100
+ left = chart_fmt(cfg[:pad][:left])
101
+ right = chart_fmt(cfg[:pad][:left] + cfg[:plot_w])
102
+ "M#{left},#{chart_fmt(base_y)} L#{chart_fmt(x)},#{chart_fmt(y)} L#{right},#{chart_fmt(base_y)} Z"
103
+ else
104
+ first_x = chart_fmt(coords.first[0])
105
+ last_x = chart_fmt(coords.last[0])
106
+ "#{line} L#{last_x},#{chart_fmt(base_y)} L#{first_x},#{chart_fmt(base_y)} Z"
107
+ end
108
+ end
109
+
110
+ def chart_dots(cfg)
111
+ cfg[:coords].each_with_index.map { |(pt_x, pt_y), idx| chart_dot(cfg, pt_x, pt_y, idx) }
112
+ end
113
+
114
+ def chart_dot(cfg, pt_x, pt_y, idx)
115
+ point = cfg[:points][idx]
116
+ title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
117
+ circle = %(<circle class="lct-chart-dot" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="3"/>)
118
+ "<g>#{circle}<title>#{title}</title></g>"
119
+ end
120
+
121
+ def chart_x_labels(cfg)
122
+ indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
123
+ label_y = chart_fmt(cfg[:height] - 8)
124
+ indexes.map { |idx| chart_x_label(cfg, idx, label_y) }
125
+ end
126
+
127
+ def chart_x_label(cfg, idx, label_y)
128
+ pt_x, = cfg[:coords][idx]
129
+ label = ERB::Util.html_escape(cfg[:points][idx][:label])
130
+ %(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="middle">#{label}</text>)
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module DashboardFilterHelper
5
+ FILTER_PARAM_KEYS = %i[from to provider model tag sort page per].freeze
6
+
7
+ def any_filter_applied?
8
+ FILTER_PARAM_KEYS.any? { |key| params[key].present? }
9
+ end
10
+
11
+ def active_tag_filters
12
+ tag_params = normalized_query_tags(params[:tag])
13
+ return [] unless tag_params.is_a?(Hash)
14
+
15
+ tag_params.filter_map do |key, value|
16
+ next if key.blank? || value.blank?
17
+
18
+ {
19
+ label: "Tag",
20
+ value: "#{key}=#{value}",
21
+ path: dashboard_filter_path(current_query(tag: tag_params.except(key.to_s).presence, page: nil))
22
+ }
23
+ end
24
+ end
25
+
26
+ def dashboard_date_range_label(from, to)
27
+ from_label = short_date_label(from) || "Any time"
28
+ to_label = short_date_label(to) || "Now"
29
+ "#{from_label} - #{to_label}"
30
+ end
31
+
32
+ private
33
+
34
+ def short_date_label(value)
35
+ return nil if value.blank?
36
+
37
+ Date.iso8601(value.to_s).strftime("%b %-d, %Y")
38
+ rescue ArgumentError
39
+ value.to_s
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module DashboardFilterOptionsHelper
5
+ def provider_filter_options(filter_params: params)
6
+ filter_options_for(:provider, filter_params: filter_params)
7
+ end
8
+
9
+ def model_filter_options(filter_params: params)
10
+ filter_options_for(:model, filter_params: filter_params)
11
+ end
12
+
13
+ private
14
+
15
+ def filter_options_for(column, filter_params:)
16
+ source = filter_source_hash(filter_params)
17
+ scope_params = source.stringify_keys.merge(
18
+ column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
19
+ )
20
+ values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
21
+ .where.not(column => [nil, ""])
22
+ .distinct.order(column).pluck(column)
23
+ current = source[column.to_s].presence || source[column].presence
24
+ values.unshift(current) if current && !values.include?(current)
25
+ values
26
+ end
27
+
28
+ def filter_source_hash(filter_params)
29
+ return filter_params.to_unsafe_h if filter_params.respond_to?(:to_unsafe_h)
30
+
31
+ filter_params.to_h
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module DashboardQueryHelper
5
+ def dashboard_filter_path(query)
6
+ cleaned = clean_dashboard_query(query)
7
+ return request.path if cleaned.blank?
8
+
9
+ "#{request.path}?#{cleaned.to_query}"
10
+ end
11
+
12
+ def calls_query_for_tag(key:, value:)
13
+ query = current_query(page: nil, per: nil, format: nil)
14
+ tags = normalized_query_tags(query[:tag])
15
+ query[:tag] = tags.merge(key.to_s => value.to_s)
16
+ query
17
+ end
18
+
19
+ private
20
+
21
+ def normalized_query_tags(tags)
22
+ return {} unless tags
23
+
24
+ tags = tags.to_unsafe_h if tags.respond_to?(:to_unsafe_h)
25
+ tags = tags.to_h if tags.respond_to?(:to_h)
26
+ return {} unless tags.is_a?(Hash)
27
+
28
+ tags.transform_keys(&:to_s).transform_values(&:to_s)
29
+ end
30
+
31
+ def clean_dashboard_query(value)
32
+ return clean_dashboard_hash(value.to_unsafe_h) if value.is_a?(ActionController::Parameters)
33
+ return clean_dashboard_hash(value) if value.is_a?(Hash)
34
+ return clean_dashboard_array(value) if value.is_a?(Array)
35
+ return clean_dashboard_string(value) if value.is_a?(String)
36
+
37
+ value
38
+ end
39
+
40
+ def clean_dashboard_hash(hash)
41
+ hash.each_with_object({}) do |(key, nested), cleaned|
42
+ nested = clean_dashboard_query(nested)
43
+ next if nested.nil? || nested == {} || nested == []
44
+
45
+ cleaned[key] = nested
46
+ end
47
+ end
48
+
49
+ def clean_dashboard_array(array)
50
+ array.filter_map { |item| clean_dashboard_query(item) }.presence
51
+ end
52
+
53
+ def clean_dashboard_string(string)
54
+ stripped = string.strip
55
+ stripped.empty? ? nil : stripped
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PaginationHelper
5
+ PER_PAGE_CHOICES = [25, 50, 100, 200].freeze
6
+
7
+ def pagination_page_items(current, total_pages, window: 1)
8
+ return (1..total_pages).to_a if total_pages <= (window * 2) + 5
9
+
10
+ anchors = [1, total_pages, current, current - window, current + window]
11
+ pages = anchors.grep(1..total_pages).uniq.sort
12
+ pages.each_with_index.flat_map do |page, index|
13
+ gap = index.positive? && page - pages[index - 1] > 1 ? [:gap] : []
14
+ gap << page
15
+ end
16
+ end
17
+ end
18
+ end
@@ -66,9 +66,6 @@ module LlmCostTracker
66
66
 
67
67
  def tag_params
68
68
  tags = hash_param(:tag)
69
- tag_key = string_param(:tag_key)
70
- tag_value = string_param(:tag_value)
71
- tags = tags.merge(tag_key => tag_value) if tag_key && tag_value
72
69
 
73
70
  tags.each_with_object({}) do |(key, value), normalized|
74
71
  value = normalized_string(value)
@@ -74,11 +74,26 @@ module LlmCostTracker
74
74
  budget = LlmCostTracker.configuration.monthly_budget
75
75
  return nil unless budget
76
76
 
77
+ now = Time.now.utc
78
+ month_start = now.beginning_of_month
79
+ month_end = now.end_of_month
77
80
  spent = LlmCostTracker::LlmApiCall.this_month.total_cost
81
+ elapsed_seconds = now - month_start
82
+ total_seconds = month_end - month_start
83
+ projected_spent = if spent.zero? || !elapsed_seconds.positive?
84
+ spent
85
+ else
86
+ spent * (total_seconds / elapsed_seconds)
87
+ end
88
+
78
89
  {
79
90
  budget: budget.to_f,
80
91
  spent: spent,
81
- percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0
92
+ percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0,
93
+ projected_spent: projected_spent,
94
+ projected_percent_used: budget.to_f.positive? ? (projected_spent / budget.to_f) * 100.0 : 0.0,
95
+ projected_delta: projected_spent - budget.to_f,
96
+ projection_end_label: month_end.strftime("%b %-d")
82
97
  }
83
98
  end
84
99
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ SpendAnomalyData = Data.define(
6
+ :provider,
7
+ :model,
8
+ :day,
9
+ :latest_spend,
10
+ :baseline_mean,
11
+ :ratio
12
+ )
13
+
14
+ class SpendAnomaly
15
+ WINDOW_DAYS = 7
16
+
17
+ class << self
18
+ def call(from:, to:, scope: LlmCostTracker::LlmApiCall.all)
19
+ new(scope: scope, from: from, to: to).alert
20
+ end
21
+ end
22
+
23
+ def initialize(scope:, from:, to:)
24
+ @scope = scope
25
+ @from = from.to_date
26
+ @to = to.to_date
27
+ end
28
+
29
+ def alert
30
+ return nil if from > (to - WINDOW_DAYS)
31
+
32
+ alerts.max_by { |item| [item.ratio || 0.0, item.latest_spend] }
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :scope, :from, :to
38
+
39
+ def alerts
40
+ daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
41
+ latest_spend = daily_costs.fetch(to, 0.0)
42
+ next unless latest_spend.positive?
43
+
44
+ baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
45
+ mean = baseline_days.sum / WINDOW_DAYS.to_f
46
+ variance = baseline_days.sum { |value| (value - mean)**2 } / WINDOW_DAYS.to_f
47
+ threshold = mean + (2 * Math.sqrt(variance))
48
+ next unless latest_spend > threshold
49
+
50
+ rows << SpendAnomalyData.new(
51
+ provider: provider,
52
+ model: model,
53
+ day: to,
54
+ latest_spend: latest_spend,
55
+ baseline_mean: mean,
56
+ ratio: mean.positive? ? (latest_spend / mean) : nil
57
+ )
58
+ end
59
+ end
60
+
61
+ def daily_spend_by_model
62
+ window = (to - WINDOW_DAYS).beginning_of_day..to.end_of_day
63
+
64
+ grouped = Hash.new { |hash, key| hash[key] = Hash.new(0.0) }
65
+
66
+ scope
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
73
+ end
74
+
75
+ grouped
76
+ end
77
+ end
78
+ end
79
+ end