llm_cost_tracker 0.2.0.alpha2 → 0.3.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +114 -70
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +760 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  15. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  16. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
  18. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  19. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  20. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  21. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  22. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  23. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  24. data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  26. data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
  27. data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
  28. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  30. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  31. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  32. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  39. data/config/routes.rb +3 -0
  40. data/lib/llm_cost_tracker/assets.rb +19 -0
  41. data/lib/llm_cost_tracker/configuration.rb +78 -42
  42. data/lib/llm_cost_tracker/engine.rb +2 -0
  43. data/lib/llm_cost_tracker/event.rb +2 -0
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  48. data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
  49. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  50. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  51. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  52. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  53. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  54. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  55. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  56. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  57. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  58. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  59. data/lib/llm_cost_tracker/price_registry.rb +18 -7
  60. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  61. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  62. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  63. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  64. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  65. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  66. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  67. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  68. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  69. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  70. data/lib/llm_cost_tracker/pricing.rb +19 -6
  71. data/lib/llm_cost_tracker/retention.rb +34 -0
  72. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  73. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  74. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  75. data/lib/llm_cost_tracker/tags_column.rb +21 -1
  76. data/lib/llm_cost_tracker/tracker.rb +15 -12
  77. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  78. data/lib/llm_cost_tracker/version.rb +1 -1
  79. data/lib/llm_cost_tracker.rb +51 -29
  80. data/lib/tasks/llm_cost_tracker.rake +124 -0
  81. data/llm_cost_tracker.gemspec +9 -8
  82. metadata +40 -12
  83. 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,12 @@
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
+ response.set_header("Cache-Control", "public, max-age=31536000, immutable")
9
+ send_file LlmCostTracker::Assets::STYLESHEET_PATH, type: "text/css", disposition: "inline"
10
+ end
11
+ end
12
+ 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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module DashboardFilterHelper
5
+ FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag sort page per].freeze
6
+
7
+ STREAM_FILTER_OPTIONS = [
8
+ ["Streaming only", "yes"],
9
+ ["Non-streaming only", "no"]
10
+ ].freeze
11
+
12
+ def any_filter_applied?
13
+ FILTER_PARAM_KEYS.any? { |key| params[key].present? }
14
+ end
15
+
16
+ def active_tag_filters
17
+ tag_params = normalized_query_tags(params[:tag])
18
+ return [] unless tag_params.is_a?(Hash)
19
+
20
+ tag_params.filter_map do |key, value|
21
+ next if key.blank? || value.blank?
22
+
23
+ {
24
+ label: "Tag",
25
+ value: "#{key}=#{value}",
26
+ path: dashboard_filter_path(current_query(tag: tag_params.except(key.to_s).presence, page: nil))
27
+ }
28
+ end
29
+ end
30
+
31
+ def dashboard_date_range_label(from, to)
32
+ from_label = short_date_label(from) || "Any time"
33
+ to_label = short_date_label(to) || "Now"
34
+ "#{from_label} - #{to_label}"
35
+ end
36
+
37
+ private
38
+
39
+ def short_date_label(value)
40
+ return nil if value.blank?
41
+
42
+ Date.iso8601(value.to_s).strftime("%b %-d, %Y")
43
+ rescue ArgumentError
44
+ value.to_s
45
+ end
46
+ end
47
+ 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
@@ -8,15 +8,18 @@ module LlmCostTracker
8
8
  :untagged_calls_count,
9
9
  :missing_latency_count,
10
10
  :latency_column_present,
11
+ :streaming_count,
12
+ :streaming_missing_usage_count,
13
+ :stream_column_present,
11
14
  :unknown_pricing_by_model
12
15
  )
13
16
 
14
- # Computes data quality metrics: coverage of cost, tags, and latency.
15
17
  class DataQuality
16
18
  class << self
17
19
  def call(scope: LlmCostTracker::LlmApiCall.all)
18
20
  total = scope.count
19
21
  latency_present = LlmCostTracker::LlmApiCall.latency_column?
22
+ stream_present = LlmCostTracker::LlmApiCall.stream_column?
20
23
 
21
24
  DataQualityStats.new(
22
25
  total_calls: total,
@@ -24,6 +27,9 @@ module LlmCostTracker
24
27
  untagged_calls_count: total - scope.with_json_tags.count,
25
28
  missing_latency_count: latency_present ? scope.where(latency_ms: nil).count : nil,
26
29
  latency_column_present: latency_present,
30
+ streaming_count: stream_present ? scope.streaming.count : nil,
31
+ streaming_missing_usage_count: streaming_missing_usage_count(scope, stream_present),
32
+ stream_column_present: stream_present,
27
33
  unknown_pricing_by_model: scope.unknown_pricing
28
34
  .group(:model)
29
35
  .order(Arel.sql("COUNT(*) DESC"))
@@ -32,6 +38,15 @@ module LlmCostTracker
32
38
  .to_h
33
39
  )
34
40
  end
41
+
42
+ private
43
+
44
+ def streaming_missing_usage_count(scope, stream_present)
45
+ return nil unless stream_present
46
+ return nil unless LlmCostTracker::LlmApiCall.usage_source_column?
47
+
48
+ scope.streaming_missing_usage.count
49
+ end
35
50
  end
36
51
  end
37
52
  end
@@ -25,6 +25,8 @@ module LlmCostTracker
25
25
  filtered_scope = apply_date_filters(filtered_scope)
26
26
  filtered_scope = apply_exact_filter(filtered_scope, :provider)
27
27
  filtered_scope = apply_exact_filter(filtered_scope, :model)
28
+ filtered_scope = apply_stream_filter(filtered_scope)
29
+ filtered_scope = apply_usage_source_filter(filtered_scope)
28
30
  apply_tag_filters(filtered_scope)
29
31
  end
30
32
 
@@ -64,11 +66,28 @@ module LlmCostTracker
64
66
  relation.by_tags(tags)
65
67
  end
66
68
 
69
+ def apply_stream_filter(relation)
70
+ value = string_param(:stream)
71
+ return relation if value.nil?
72
+ return relation unless relation.klass.stream_column?
73
+
74
+ case value.downcase
75
+ when "yes", "true", "1" then relation.where(stream: true)
76
+ when "no", "false", "0" then relation.where(stream: [false, nil])
77
+ else relation
78
+ end
79
+ end
80
+
81
+ def apply_usage_source_filter(relation)
82
+ value = string_param(:usage_source)
83
+ return relation if value.nil?
84
+ return relation unless relation.klass.usage_source_column?
85
+
86
+ relation.where(usage_source: value)
87
+ end
88
+
67
89
  def tag_params
68
90
  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
91
 
73
92
  tags.each_with_object({}) do |(key, value), normalized|
74
93
  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