llm_cost_tracker 0.1.3 → 0.2.0.alpha1

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -81
  3. data/PLAN_0.2.md +488 -0
  4. data/README.md +141 -316
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
  14. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
  15. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
  16. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
  17. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
  18. data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
  20. data/app/services/llm_cost_tracker/pagination.rb +59 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
  29. data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
  31. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
  32. data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
  34. data/config/routes.rb +10 -0
  35. data/lib/llm_cost_tracker/budget.rb +16 -38
  36. data/lib/llm_cost_tracker/configuration.rb +3 -1
  37. data/lib/llm_cost_tracker/cost.rb +1 -3
  38. data/lib/llm_cost_tracker/engine.rb +13 -0
  39. data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
  40. data/lib/llm_cost_tracker/errors.rb +2 -0
  41. data/lib/llm_cost_tracker/event.rb +1 -3
  42. data/lib/llm_cost_tracker/event_metadata.rb +9 -18
  43. data/lib/llm_cost_tracker/llm_api_call.rb +43 -9
  44. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
  45. data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
  46. data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
  47. data/lib/llm_cost_tracker/parsers/base.rb +3 -8
  48. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  50. data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
  51. data/lib/llm_cost_tracker/period_grouping.rb +68 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +22 -30
  53. data/lib/llm_cost_tracker/pricing.rb +10 -19
  54. data/lib/llm_cost_tracker/report.rb +4 -4
  55. data/lib/llm_cost_tracker/report_data.rb +23 -29
  56. data/lib/llm_cost_tracker/report_formatter.rb +11 -3
  57. data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
  58. data/lib/llm_cost_tracker/tag_accessors.rb +0 -8
  59. data/lib/llm_cost_tracker/tag_key.rb +16 -0
  60. data/lib/llm_cost_tracker/tracker.rb +35 -1
  61. data/lib/llm_cost_tracker/unknown_pricing.rb +1 -1
  62. data/lib/llm_cost_tracker/version.rb +1 -1
  63. data/lib/llm_cost_tracker.rb +3 -6
  64. data/llm_cost_tracker.gemspec +13 -9
  65. metadata +92 -21
  66. data/.rubocop.yml +0 -44
  67. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
  68. data/lib/llm_cost_tracker/storage/backends.rb +0 -26
  69. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
  70. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
  71. data/lib/llm_cost_tracker/value_object.rb +0 -45
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ApplicationController < ActionController::Base
5
+ layout "llm_cost_tracker/application"
6
+
7
+ before_action :ensure_llm_api_calls_table
8
+
9
+ rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
10
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
11
+ rescue_from ActiveRecord::StatementInvalid, with: :render_database_error
12
+ rescue_from LlmCostTracker::InvalidFilterError, with: :render_invalid_filter
13
+
14
+ private
15
+
16
+ def ensure_llm_api_calls_table
17
+ return if llm_api_calls_table_available?
18
+
19
+ render template: "llm_cost_tracker/shared/setup_required"
20
+ end
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
+ def render_database_error(error)
29
+ @error = error
30
+ render "llm_cost_tracker/errors/database", status: :internal_server_error
31
+ end
32
+
33
+ def render_invalid_filter(error)
34
+ @error_message = error.message
35
+ render "llm_cost_tracker/errors/invalid_filter", status: :bad_request
36
+ end
37
+
38
+ def render_not_found
39
+ render "llm_cost_tracker/errors/not_found", status: :not_found
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module LlmCostTracker
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
+ CSV_EXPORT_LIMIT = 10_000
16
+
17
+ def index
18
+ @sort = params[:sort].to_s
19
+ scope = Dashboard::Filter.call(params: params)
20
+ scope = scope.unknown_pricing if @sort == "unknown_pricing"
21
+ ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
22
+ @latency_available = LlmApiCall.latency_column?
23
+
24
+ respond_to do |format|
25
+ format.html do
26
+ @page = Pagination.call(params)
27
+ @calls_count = scope.count
28
+ @calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
29
+ end
30
+ format.csv do
31
+ send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
32
+ type: "text/csv",
33
+ disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
34
+ end
35
+ end
36
+ end
37
+
38
+ def show
39
+ @call = LlmApiCall.find(params[:id])
40
+ @tags = @call.parsed_tags
41
+ @metadata_available = @call.has_attribute?("metadata")
42
+ @metadata = @call.read_attribute("metadata") if @metadata_available
43
+ @latency_available = LlmApiCall.latency_column?
44
+ end
45
+
46
+ private
47
+
48
+ def calls_order(sort)
49
+ SORT_ORDERS[sort] || "tracked_at DESC, id DESC"
50
+ end
51
+
52
+ def render_csv(relation)
53
+ latency = LlmApiCall.latency_column?
54
+ CSV.generate do |csv|
55
+ headers = %w[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
56
+ headers << "latency_ms" if latency
57
+ headers << "tags"
58
+ csv << headers
59
+
60
+ relation.each do |call|
61
+ row = [
62
+ call.tracked_at&.utc&.iso8601,
63
+ call.provider,
64
+ call.model,
65
+ call.input_tokens,
66
+ call.output_tokens,
67
+ call.total_tokens,
68
+ call.total_cost
69
+ ]
70
+ row << call.latency_ms if latency
71
+ row << call.parsed_tags.to_json
72
+ csv << row
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class DashboardController < ApplicationController
5
+ def index
6
+ @from_date, @to_date = overview_range
7
+ scope = Dashboard::Filter.call(params: overview_filter_params)
8
+ previous_scope = Dashboard::Filter.call(params: previous_filter_params)
9
+
10
+ @stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
11
+ @time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
12
+ @top_models = Dashboard::TopModels.call(scope: scope, limit: 5)
13
+ @providers = Dashboard::ProviderBreakdown.call(scope: scope)
14
+ end
15
+
16
+ private
17
+
18
+ def overview_range
19
+ to_date = parsed_date(params[:to]) || Date.current
20
+ from_date = parsed_date(params[:from]) || (to_date - 29)
21
+ [from_date, to_date]
22
+ end
23
+
24
+ def previous_range
25
+ span_days = (@to_date - @from_date).to_i + 1
26
+ prev_to = @from_date - 1
27
+ prev_from = prev_to - (span_days - 1)
28
+ [prev_from, prev_to]
29
+ end
30
+
31
+ def overview_filter_params
32
+ params.to_unsafe_h.merge(
33
+ "from" => @from_date.iso8601,
34
+ "to" => @to_date.iso8601
35
+ )
36
+ end
37
+
38
+ def previous_filter_params
39
+ prev_from, prev_to = previous_range
40
+ params.to_unsafe_h.merge(
41
+ "from" => prev_from.iso8601,
42
+ "to" => prev_to.iso8601
43
+ )
44
+ end
45
+
46
+ def parsed_date(value)
47
+ return nil if value.to_s.strip.empty?
48
+
49
+ Date.iso8601(value.to_s)
50
+ rescue ArgumentError
51
+ nil
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class DataQualityController < ApplicationController
5
+ def index
6
+ scope = Dashboard::Filter.call(params: params)
7
+ @stats = Dashboard::DataQuality.call(scope: scope)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ModelsController < ApplicationController
5
+ def index
6
+ scope = Dashboard::Filter.call(params: params)
7
+ @sort = params[:sort].to_s
8
+ @rows = Dashboard::TopModels.call(scope: scope, limit: nil, sort: @sort)
9
+ @latency_available = LlmApiCall.latency_column?
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class TagsController < ApplicationController
5
+ def index
6
+ scope = Dashboard::Filter.call(params: params)
7
+ @rows = Dashboard::TagKeyExplorer.call(scope: scope)
8
+ end
9
+
10
+ def show
11
+ @tag_key = params[:key]
12
+ scope = Dashboard::Filter.call(params: params)
13
+ @rows = Dashboard::TagBreakdown.call(scope: scope, key: @tag_key)
14
+ @total_calls = @rows.sum(&:calls)
15
+
16
+ tagged_rows = @rows.reject { |r| r.value == "(untagged)" }
17
+ @tagged_calls = tagged_rows.sum(&:calls)
18
+ @distinct_values = tagged_rows.size
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module ApplicationHelper
7
+ def coverage_percent(numerator, denominator)
8
+ return 0.0 unless denominator.to_i.positive?
9
+
10
+ (numerator.to_f / denominator) * 100.0
11
+ end
12
+
13
+ def money(value)
14
+ value = value.to_f
15
+ precision = value.abs < 0.01 && value != 0.0 ? 6 : 2
16
+
17
+ "$#{format("%.#{precision}f", value)}"
18
+ end
19
+
20
+ def optional_money(value)
21
+ value.nil? ? "n/a" : money(value)
22
+ end
23
+
24
+ def optional_number(value)
25
+ value.nil? ? "n/a" : number(value)
26
+ end
27
+
28
+ def number(value)
29
+ number_with_delimiter(value.to_i)
30
+ end
31
+
32
+ def format_tokens(value)
33
+ number(value)
34
+ end
35
+
36
+ def format_date(value)
37
+ value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d %H:%M") : value.to_s
38
+ end
39
+
40
+ def pricing_status(call)
41
+ call.total_cost.nil? ? "Unknown pricing" : "Estimated"
42
+ end
43
+
44
+ def percent(value)
45
+ "#{format('%.1f', value.to_f)}%"
46
+ end
47
+
48
+ 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?
50
+
51
+ rounded = delta_percent.round(1)
52
+ return { text: "= vs. prior", css_class: "lct-delta lct-delta-neutral" } if rounded.zero?
53
+
54
+ sign = rounded.positive? ? "+" : ""
55
+ text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
56
+ css_class = if mode == :neutral
57
+ "lct-delta lct-delta-neutral"
58
+ elsif rounded.positive?
59
+ "lct-delta lct-delta-up"
60
+ else
61
+ "lct-delta lct-delta-down"
62
+ end
63
+
64
+ { text: text, css_class: css_class }
65
+ end
66
+
67
+ def bar_width(value, max)
68
+ max = max.to_f
69
+ return "0%" unless max.positive?
70
+
71
+ "#{[(value.to_f / max) * 100.0, 100.0].min.round(2)}%"
72
+ end
73
+
74
+ def safe_json(value)
75
+ parsed = value.is_a?(String) ? JSON.parse(value) : value
76
+ JSON.pretty_generate(parsed || {})
77
+ rescue JSON::ParserError, TypeError
78
+ value.to_s
79
+ end
80
+
81
+ def tags_summary(tags, limit: 3)
82
+ tags = normalized_tags(tags)
83
+ return "(untagged)" if tags.empty?
84
+
85
+ summary = tags.first(limit).map { |key, value| "#{key}=#{tag_value_summary(value)}" }
86
+ summary << "+#{tags.size - limit}" if tags.size > limit
87
+ summary.join(", ")
88
+ end
89
+
90
+ def current_query(overrides = {})
91
+ request.query_parameters.symbolize_keys.merge(overrides)
92
+ end
93
+
94
+ private
95
+
96
+ def normalized_tags(tags)
97
+ return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
98
+
99
+ JSON.parse(tags || "{}")
100
+ rescue JSON::ParserError, TypeError
101
+ {}
102
+ end
103
+
104
+ def tag_value_summary(value)
105
+ case value
106
+ when Hash, Array
107
+ JSON.generate(value)
108
+ else
109
+ value.to_s
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ DataQualityStats = Data.define(
6
+ :total_calls,
7
+ :unknown_pricing_count,
8
+ :untagged_calls_count,
9
+ :missing_latency_count,
10
+ :latency_column_present,
11
+ :unknown_pricing_by_model
12
+ )
13
+
14
+ # Computes data quality metrics: coverage of cost, tags, and latency.
15
+ class DataQuality
16
+ class << self
17
+ def call(scope: LlmCostTracker::LlmApiCall.all)
18
+ total = scope.count
19
+ latency_present = LlmCostTracker::LlmApiCall.latency_column?
20
+
21
+ DataQualityStats.new(
22
+ total_calls: total,
23
+ unknown_pricing_count: scope.unknown_pricing.count,
24
+ untagged_calls_count: total - scope.with_json_tags.count,
25
+ missing_latency_count: latency_present ? scope.where(latency_ms: nil).count : nil,
26
+ latency_column_present: latency_present,
27
+ unknown_pricing_by_model: scope.unknown_pricing
28
+ .group(:model)
29
+ .order(Arel.sql("COUNT(*) DESC"))
30
+ .count
31
+ .first(10)
32
+ .to_h
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module LlmCostTracker
6
+ module Dashboard
7
+ # Parses dashboard params into an ActiveRecord relation.
8
+ #
9
+ # Invalid dates are ignored, pagination is handled elsewhere, and invalid
10
+ # tag keys raise InvalidFilterError so controllers can fail closed with HTTP 400.
11
+ class Filter
12
+ class << self
13
+ def call(scope: LlmCostTracker::LlmApiCall.all, params: {})
14
+ new(scope: scope, params: params).relation
15
+ end
16
+ end
17
+
18
+ def initialize(scope:, params:)
19
+ @scope = scope
20
+ @params = normalize_params(params)
21
+ end
22
+
23
+ def relation
24
+ filtered_scope = scope
25
+ filtered_scope = apply_date_filters(filtered_scope)
26
+ filtered_scope = apply_exact_filter(filtered_scope, :provider)
27
+ filtered_scope = apply_exact_filter(filtered_scope, :model)
28
+ apply_tag_filters(filtered_scope)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :scope, :params
34
+
35
+ def normalize_params(params)
36
+ return {}.with_indifferent_access if params.nil?
37
+
38
+ raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
39
+ raw.with_indifferent_access
40
+ rescue NoMethodError
41
+ {}.with_indifferent_access
42
+ end
43
+
44
+ def apply_date_filters(relation)
45
+ from = parse_date(:from)&.beginning_of_day
46
+ to = parse_date(:to)&.end_of_day
47
+
48
+ relation = relation.where(tracked_at: from..) if from
49
+ relation = relation.where(tracked_at: ..to) if to
50
+ relation
51
+ end
52
+
53
+ def apply_exact_filter(relation, key)
54
+ value = string_param(key)
55
+ return relation if value.nil?
56
+
57
+ relation.where(key => value)
58
+ end
59
+
60
+ def apply_tag_filters(relation)
61
+ tags = tag_params
62
+ return relation if tags.empty?
63
+
64
+ relation.by_tags(tags)
65
+ end
66
+
67
+ def tag_params
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
+
73
+ tags.each_with_object({}) do |(key, value), normalized|
74
+ value = normalized_string(value)
75
+ next if value.nil?
76
+
77
+ normalized[LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
78
+ end
79
+ end
80
+
81
+ def hash_param(key)
82
+ raw = params[key]
83
+ raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
84
+ raw = raw.to_h if raw.respond_to?(:to_h)
85
+ raw.is_a?(Hash) ? raw : {}
86
+ end
87
+
88
+ def parse_date(key)
89
+ value = string_param(key)
90
+ return nil if value.nil?
91
+
92
+ Date.iso8601(value)
93
+ rescue ArgumentError
94
+ nil
95
+ end
96
+
97
+ def string_param(key)
98
+ normalized_string(params[key])
99
+ end
100
+
101
+ def normalized_string(value)
102
+ return nil if value.nil?
103
+
104
+ value = value.to_s.strip
105
+ value.empty? ? nil : value
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ OverviewStatsData = Data.define(
6
+ :total_cost,
7
+ :total_calls,
8
+ :average_cost_per_call,
9
+ :average_latency_ms,
10
+ :unknown_pricing_count,
11
+ :previous_total_cost,
12
+ :previous_total_calls,
13
+ :cost_delta_percent,
14
+ :calls_delta_percent,
15
+ :monthly_budget_status
16
+ )
17
+
18
+ class OverviewStats
19
+ class << self
20
+ def call(scope: LlmCostTracker::LlmApiCall.all, previous_scope: nil)
21
+ current = aggregate(scope)
22
+ total_calls = current.calls_count.to_i
23
+ total_cost = current.total_cost_sum.to_f
24
+
25
+ previous = previous_scope && aggregate(previous_scope)
26
+ prev_cost = previous&.total_cost_sum.to_f
27
+ prev_calls = previous&.calls_count.to_i
28
+
29
+ OverviewStatsData.new(
30
+ total_cost: total_cost,
31
+ total_calls: total_calls,
32
+ average_cost_per_call: total_calls.positive? ? total_cost / total_calls : 0.0,
33
+ average_latency_ms: latency_value(current, scope),
34
+ unknown_pricing_count: current.unknown_pricing_count.to_i,
35
+ previous_total_cost: previous ? prev_cost : nil,
36
+ previous_total_calls: previous ? prev_calls : nil,
37
+ cost_delta_percent: previous ? delta_percent(total_cost, prev_cost) : nil,
38
+ calls_delta_percent: previous ? delta_percent(total_calls, prev_calls) : nil,
39
+ monthly_budget_status: budget_status
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def aggregate(scope)
46
+ scope.select(aggregate_selects(scope)).take
47
+ end
48
+
49
+ def aggregate_selects(scope)
50
+ selects = [
51
+ "COUNT(*) AS calls_count",
52
+ "COALESCE(SUM(total_cost), 0) AS total_cost_sum",
53
+ "SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END) AS unknown_pricing_count"
54
+ ]
55
+ selects << "AVG(latency_ms) AS average_latency" if scope.klass.latency_column?
56
+ selects.join(", ")
57
+ end
58
+
59
+ def latency_value(row, scope)
60
+ return nil unless scope.klass.latency_column?
61
+
62
+ row.average_latency&.to_f
63
+ end
64
+
65
+ def delta_percent(current, previous)
66
+ current = current.to_f
67
+ previous = previous.to_f
68
+ return nil if previous.zero?
69
+
70
+ ((current - previous) / previous) * 100.0
71
+ end
72
+
73
+ def budget_status
74
+ budget = LlmCostTracker.configuration.monthly_budget
75
+ return nil unless budget
76
+
77
+ spent = LlmCostTracker::LlmApiCall.this_month.total_cost
78
+ {
79
+ budget: budget.to_f,
80
+ spent: spent,
81
+ percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ ProviderRow = Data.define(:provider, :calls, :total_cost, :share_percent)
6
+
7
+ # Aggregates cost and call counts per provider for a given scope.
8
+ # Sorted by total cost descending; providers with zero cost fall to the bottom
9
+ # but are still returned so users can see calls without pricing.
10
+ class ProviderBreakdown
11
+ def self.call(scope: LlmCostTracker::LlmApiCall.all)
12
+ new(scope: scope).rows
13
+ end
14
+
15
+ def initialize(scope:)
16
+ @scope = scope
17
+ end
18
+
19
+ def rows
20
+ grouped = scope
21
+ .group(:provider)
22
+ .select("provider, COUNT(*) AS calls_count, COALESCE(SUM(total_cost), 0) AS total_cost_sum")
23
+ .order(Arel.sql("total_cost_sum DESC, calls_count DESC"))
24
+ .to_a
25
+
26
+ total_cost = grouped.sum { |row| row.total_cost_sum.to_f }
27
+
28
+ grouped.map do |row|
29
+ cost = row.total_cost_sum.to_f
30
+ ProviderRow.new(
31
+ provider: row.provider,
32
+ calls: row.calls_count.to_i,
33
+ total_cost: cost,
34
+ share_percent: total_cost.positive? ? (cost / total_cost) * 100.0 : 0.0
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :scope
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ TagBreakdownRow = Data.define(
6
+ :value,
7
+ :calls,
8
+ :total_cost,
9
+ :average_cost_per_call
10
+ )
11
+
12
+ # Aggregates calls grouped by the distinct values of a single tag key.
13
+ # Invalid keys raise InvalidFilterError so controllers can return HTTP 400.
14
+ class TagBreakdown
15
+ class << self
16
+ def call(key:, scope: LlmCostTracker::LlmApiCall.all)
17
+ new(scope: scope, key: key).rows
18
+ end
19
+ end
20
+
21
+ def initialize(scope:, key:)
22
+ @scope = scope
23
+ @key = LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
24
+ end
25
+
26
+ def rows
27
+ costs = scope.cost_by_tag(key)
28
+ counts = counts_by_tag
29
+
30
+ costs.map do |value, total_cost|
31
+ calls = counts[value].to_i
32
+ total_cost = total_cost.to_f
33
+
34
+ TagBreakdownRow.new(
35
+ value: value,
36
+ calls: calls,
37
+ total_cost: total_cost,
38
+ average_cost_per_call: calls.positive? ? total_cost / calls : 0.0
39
+ )
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :scope, :key
46
+
47
+ def counts_by_tag
48
+ scope.group_by_tag(key).count.each_with_object(Hash.new(0)) do |(raw, count), hash|
49
+ hash[label(raw)] += count.to_i
50
+ end
51
+ end
52
+
53
+ def label(value)
54
+ value.nil? || value == "" ? "(untagged)" : value.to_s
55
+ end
56
+ end
57
+ end
58
+ end