llm_cost_tracker 0.10.0 → 0.11.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +4 -1
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +15 -3
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
  17. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  25. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  26. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  27. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  28. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  33. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  34. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  36. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  37. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  38. data/config/routes.rb +1 -0
  39. data/lib/llm_cost_tracker/budget.rb +1 -2
  40. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
  43. data/lib/llm_cost_tracker/configuration.rb +3 -20
  44. data/lib/llm_cost_tracker/doctor.rb +74 -8
  45. data/lib/llm_cost_tracker/event.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
  49. data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
  50. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
  51. data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
  52. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
  53. data/lib/llm_cost_tracker/ledger.rb +13 -0
  54. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
  55. data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
  56. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
  57. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
  58. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  59. data/lib/llm_cost_tracker/parsers.rb +0 -2
  60. data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
  61. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  62. data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
  63. data/lib/llm_cost_tracker/pricing.rb +1 -18
  64. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  65. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  66. data/lib/llm_cost_tracker/railtie.rb +0 -4
  67. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  68. data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
  69. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
  70. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  71. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  72. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  73. data/lib/llm_cost_tracker/tracker.rb +1 -1
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. metadata +14 -6
  76. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  77. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  78. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  79. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
@@ -10,6 +10,7 @@ module LlmCostTracker
10
10
 
11
11
  before_action :set_dashboard_security_headers
12
12
  before_action :ensure_current_schema
13
+ before_action :assign_dashboard_date_range
13
14
 
14
15
  helper_method :dashboard_csp_nonce
15
16
 
@@ -30,6 +31,12 @@ module LlmCostTracker
30
31
  render template: "llm_cost_tracker/shared/setup_required"
31
32
  end
32
33
 
34
+ def assign_dashboard_date_range
35
+ range = LlmCostTracker::Dashboard::DateRange.call(params: params)
36
+ @from_date = range.from
37
+ @to_date = range.to
38
+ end
39
+
33
40
  def render_database_error(error)
34
41
  @error = error
35
42
  render "llm_cost_tracker/errors/database", status: :internal_server_error
@@ -48,8 +55,13 @@ module LlmCostTracker
48
55
  nonce = dashboard_csp_nonce
49
56
  response.headers["X-Frame-Options"] = "DENY"
50
57
  response.headers["Referrer-Policy"] = "same-origin"
51
- response.headers["Content-Security-Policy"] =
52
- "default-src 'self'; style-src 'self' 'nonce-#{nonce}'; img-src 'self' data:; frame-ancestors 'none'"
58
+ response.headers["Content-Security-Policy"] = [
59
+ "default-src 'self'",
60
+ "script-src 'self' 'nonce-#{nonce}'",
61
+ "style-src 'self' 'nonce-#{nonce}'",
62
+ "img-src 'self' data:",
63
+ "frame-ancestors 'none'"
64
+ ].join("; ")
53
65
  end
54
66
 
55
67
  def dashboard_csp_nonce
@@ -8,18 +8,24 @@ module LlmCostTracker
8
8
  CSV_EXPORT_LIMIT = 10_000
9
9
  CSV_EXPORT_BATCH_SIZE = 500
10
10
  CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
11
- DEFAULT_ORDER = "tracked_at DESC, id DESC"
11
+ DEFAULT_TIEBREAKER = { tracked_at: :desc, id: :desc }.freeze
12
+ SORT_OPTIONS = %w[tracked_at provider model input output cost latency].freeze
13
+ NULLS_LAST_GUARD = {
14
+ total_cost: Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC"),
15
+ latency_ms: Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
16
+ }.freeze
12
17
 
13
18
  def index
14
19
  @sort = params[:sort].to_s
20
+ @dir = params[:dir].to_s
15
21
  scope = Dashboard::Filter.call(params: params)
16
- scope = scope.unknown_pricing if @sort == "unknown_pricing"
17
- ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
22
+ scope = scope.unknown_pricing if params[:cost_status].to_s == "incomplete"
23
+ ordered_scope = scope.order(*calls_order(@sort, @dir))
18
24
 
19
25
  respond_to do |format|
20
26
  format.html do
21
27
  @page = Dashboard::Pagination.call(params)
22
- @calls_count = scope.count
28
+ @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
23
29
  @calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
24
30
  end
25
31
  format.csv do
@@ -37,18 +43,19 @@ module LlmCostTracker
37
43
 
38
44
  private
39
45
 
40
- def calls_order(sort)
41
- case sort
42
- when "expensive"
43
- "CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{DEFAULT_ORDER}"
44
- when "input"
45
- "input_tokens DESC, #{DEFAULT_ORDER}"
46
- when "output"
47
- "output_tokens DESC, #{DEFAULT_ORDER}"
48
- when "slow"
49
- "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
50
- else
51
- DEFAULT_ORDER
46
+ def calls_order(sort, dir)
47
+ column = SORT_OPTIONS.include?(sort) ? sort.to_sym : :tracked_at
48
+ natural = %i[provider model].include?(column) ? :asc : :desc
49
+ direction = Dashboard::Sort::DIRECTIONS.include?(dir.downcase) ? dir.downcase.to_sym : natural
50
+
51
+ case column
52
+ when :tracked_at then [{ tracked_at: direction, id: direction }]
53
+ when :provider then [{ provider: direction, model: :asc, **DEFAULT_TIEBREAKER }]
54
+ when :model then [{ model: direction, **DEFAULT_TIEBREAKER }]
55
+ when :input then [{ input_tokens: direction, **DEFAULT_TIEBREAKER }]
56
+ when :output then [{ output_tokens: direction, **DEFAULT_TIEBREAKER }]
57
+ when :cost then [NULLS_LAST_GUARD[:total_cost], { total_cost: direction, **DEFAULT_TIEBREAKER }]
58
+ when :latency then [NULLS_LAST_GUARD[:latency_ms], { latency_ms: direction, **DEFAULT_TIEBREAKER }]
52
59
  end
53
60
  end
54
61
 
@@ -3,9 +3,6 @@
3
3
  module LlmCostTracker
4
4
  class DashboardController < ApplicationController
5
5
  def index
6
- range = Dashboard::DateRange.call(params: params)
7
- @from_date = range.from
8
- @to_date = range.to
9
6
  prev_from, prev_to = previous_range
10
7
  filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
11
8
  scope = Dashboard::Filter.call(
@@ -4,10 +4,12 @@ module LlmCostTracker
4
4
  class ModelsController < ApplicationController
5
5
  def index
6
6
  @sort = params[:sort].to_s
7
+ @dir = params[:dir].to_s
7
8
  @rows = Dashboard::TopModels.call(
8
9
  scope: Dashboard::Filter.call(params: params),
9
10
  limit: nil,
10
- sort: @sort
11
+ sort: @sort,
12
+ direction: @dir
11
13
  )
12
14
  end
13
15
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class PricingController < ApplicationController
5
+ def index
6
+ @overview = Dashboard::PricingOverview.call
7
+ requested = params[:source].to_s.to_sym
8
+ @active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
9
+ @source_data = @overview.fetch(:sources).fetch(@active_source)
10
+ @provider_filter = params[:provider].to_s.presence
11
+ @rows = @source_data.fetch(:rows)
12
+ @rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
13
+ @providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
14
+ end
15
+ end
16
+ end
@@ -30,22 +30,18 @@ module LlmCostTracker
30
30
  return redirect_to reconciliation_path, alert: "No importer configured for #{source}" if importer.nil?
31
31
 
32
32
  result = importer.call
33
- if result.respond_to?(:errors) && result.errors.any?
33
+ if result.errors.any?
34
34
  LlmCostTracker::Logging.warn(
35
35
  "Reconciliation import for #{source} returned #{result.errors.size} row error(s)"
36
36
  )
37
37
  return redirect_to(
38
38
  reconciliation_path,
39
- alert: "Imported #{result.respond_to?(:total_imported) ? result.total_imported : 0} " \
40
- "#{source} rows with #{result.errors.size} row error(s); see Rails logs for details."
39
+ alert: "Imported #{result.total_imported} #{source} rows " \
40
+ "with #{result.errors.size} row error(s); see Rails logs for details."
41
41
  )
42
42
  end
43
- message = if result.respond_to?(:total_imported)
44
- "Imported #{result.total_imported} #{source} rows"
45
- else
46
- "Triggered #{source} importer"
47
- end
48
- redirect_to reconciliation_path, notice: message
43
+ redirect_to reconciliation_path,
44
+ notice: "Imported #{result.total_imported} #{source} rows"
49
45
  rescue StandardError => e
50
46
  LlmCostTracker::Logging.warn("Reconciliation import failed for #{source}: #{e.class}: #{e.message}")
51
47
  redirect_to reconciliation_path,
@@ -59,13 +55,7 @@ module LlmCostTracker
59
55
  end
60
56
 
61
57
  def invoice_scopes
62
- connection = LlmCostTracker::ProviderInvoice.connection
63
- provider_expr =
64
- if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
65
- Arel.sql("metadata->>'provider'")
66
- else
67
- Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
68
- end
58
+ provider_expr = Arel.sql(metadata_provider_sql)
69
59
  LlmCostTracker::ProviderInvoice
70
60
  .group(:source, provider_expr, :currency)
71
61
  .order(:source, :currency)
@@ -92,14 +82,18 @@ module LlmCostTracker
92
82
  def scope_invoices(scope)
93
83
  relation = LlmCostTracker::ProviderInvoice
94
84
  .where(source: scope[:source], currency: scope[:currency])
95
- connection = LlmCostTracker::ProviderInvoice.connection
96
85
  provider = scope[:provider]
97
86
  return relation if provider.nil? || provider.empty?
98
87
 
88
+ relation.where("#{metadata_provider_sql} = ?", provider)
89
+ end
90
+
91
+ def metadata_provider_sql
92
+ connection = LlmCostTracker::ProviderInvoice.connection
99
93
  if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
100
- relation.where("metadata->>'provider' = ?", provider)
94
+ "metadata->>'provider'"
101
95
  else
102
- relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
96
+ "JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))"
103
97
  end
104
98
  end
105
99
  end
@@ -11,7 +11,9 @@ module LlmCostTracker
11
11
  @value = params[:tag_value].to_s
12
12
 
13
13
  if @value.empty?
14
- @breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key])
14
+ @sort = params[:sort].to_s
15
+ @dir = params[:dir].to_s
16
+ @breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key], sort: @sort, direction: @dir)
15
17
  else
16
18
  @key = LlmCostTracker::Tags::Key.validate!(
17
19
  params[:key],
@@ -15,6 +15,18 @@ module LlmCostTracker
15
15
  include TokenUsageHelper
16
16
  include InlineStyleHelper
17
17
 
18
+ def dashboard_section
19
+ path = request.path.to_s
20
+ return :models if path.start_with?(models_path)
21
+ return :calls if path.start_with?(calls_path)
22
+ return :tags if path.start_with?(tags_path)
23
+ return :data_quality if path.start_with?(data_quality_path)
24
+ return :pricing if path.start_with?(pricing_path)
25
+ return :reconciliation if LlmCostTracker.reconciliation_enabled? && path.start_with?(reconciliation_path)
26
+
27
+ :overview
28
+ end
29
+
18
30
  def coverage_percent(numerator, denominator)
19
31
  denominator = denominator.to_f
20
32
  return 0.0 unless denominator.positive?
@@ -46,14 +58,14 @@ module LlmCostTracker
46
58
  end
47
59
 
48
60
  def pricing_status(call)
49
- return "Unknown pricing" if call.total_cost.nil?
61
+ return "Unknown" if call.total_cost.nil?
50
62
  return "Estimated" unless call.has_attribute?(:cost_status)
51
63
 
52
64
  {
53
65
  LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
54
66
  LlmCostTracker::Billing::CostStatus::FREE => "Free",
55
- LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial pricing"
56
- }.fetch(call.cost_status, "Unknown pricing")
67
+ LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial"
68
+ }.fetch(call.cost_status, "Unknown")
57
69
  end
58
70
 
59
71
  def percent(value)
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  return nil if points.blank?
7
7
 
8
8
  cfg = chart_config(points, comparison_points, height, y_ticks)
9
- parts = [chart_svg_open(cfg)]
9
+ parts = [chart_svg_open(cfg), "<title>Daily spend trend</title>", chart_area_gradient_def]
10
10
  parts.concat(chart_grid_and_axis(cfg))
11
11
  parts << chart_paths(cfg)
12
12
  parts.concat(chart_dots(cfg))
@@ -22,7 +22,7 @@ module LlmCostTracker
22
22
  end
23
23
 
24
24
  def chart_config(points, comparison_points, height, y_ticks)
25
- width = 720
25
+ width = 1180
26
26
  pad = { top: 16, right: 16, bottom: 28, left: 56 }
27
27
  plot_w = width - pad[:left] - pad[:right]
28
28
  plot_h = height - pad[:top] - pad[:bottom]
@@ -31,9 +31,11 @@ module LlmCostTracker
31
31
  coords = chart_coords(points, pad, plot_w, plot_h, max_cost)
32
32
  comparison_coords = chart_coords(comparison_points, pad, plot_w, plot_h, max_cost) if comparison_points.present?
33
33
 
34
+ peak_index = points.each_with_index.max_by { |point, _| point[:cost].to_f }&.last
34
35
  { width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
35
36
  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
+ comparison_points: comparison_points, comparison_coords: comparison_coords,
38
+ peak_index: peak_index }
37
39
  end
38
40
 
39
41
  def chart_coords(points, pad, plot_w, plot_h, max_cost)
@@ -51,7 +53,7 @@ module LlmCostTracker
51
53
  attrs = [
52
54
  %(class="lct-chart"),
53
55
  %(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
54
- %(preserveAspectRatio="none"),
56
+ %(preserveAspectRatio="xMidYMid meet"),
55
57
  %(role="img"),
56
58
  %(aria-label="Daily spend trend")
57
59
  ].join(" ")
@@ -114,10 +116,20 @@ module LlmCostTracker
114
116
  def chart_dot(cfg, pt_x, pt_y, idx)
115
117
  point = cfg[:points][idx]
116
118
  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"/>)
119
+ peak = idx == cfg[:peak_index]
120
+ klass = peak ? "lct-chart-peak" : "lct-chart-dot"
121
+ radius = peak ? 4 : 3
122
+ circle = %(<circle class="#{klass}" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="#{radius}"/>)
118
123
  "<g>#{circle}<title>#{title}</title></g>"
119
124
  end
120
125
 
126
+ def chart_area_gradient_def
127
+ "<defs><linearGradient id=\"lct-chart-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" \
128
+ "<stop offset=\"0%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.28\"/>" \
129
+ "<stop offset=\"100%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.02\"/>" \
130
+ "</linearGradient></defs>"
131
+ end
132
+
121
133
  def chart_x_labels(cfg)
122
134
  indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
123
135
  label_y = chart_fmt(cfg[:height] - 8)
@@ -127,7 +139,11 @@ module LlmCostTracker
127
139
  def chart_x_label(cfg, idx, label_y)
128
140
  pt_x, = cfg[:coords][idx]
129
141
  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>)
142
+ anchor = if idx.zero? then "start"
143
+ elsif idx == cfg[:n] - 1 then "end"
144
+ else "middle"
145
+ end
146
+ %(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="#{anchor}">#{label}</text>)
131
147
  end
132
148
  end
133
149
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module SortableTableHelper
5
+ def sortable_header(label, column, num: false)
6
+ state = sortable_state(column, num: num)
7
+ classes = ["lct-sortable"]
8
+ classes << "lct-num" if num
9
+ classes << "lct-sorted" if state[:active]
10
+
11
+ href = dashboard_filter_path(current_query(sort: column, dir: state[:next_dir], page: nil))
12
+ tag.th(class: classes.join(" "), "aria-sort": state[:aria_sort]) do
13
+ link_to(href) { safe_join([label, " ", tag.span(state[:arrow], class: "lct-sort-ind")]) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def sortable_state(column, num:)
20
+ current_sort = params[:sort].to_s
21
+ current_dir = Dashboard::Sort::DIRECTIONS.include?(params[:dir].to_s) ? params[:dir].to_s : nil
22
+ natural_dir = num ? "desc" : "asc"
23
+ active = current_sort == column
24
+ effective_dir = active ? (current_dir || natural_dir) : natural_dir
25
+ flipped = effective_dir == "asc" ? "desc" : "asc"
26
+
27
+ {
28
+ active: active,
29
+ next_dir: active ? flipped : natural_dir,
30
+ arrow: active && effective_dir == "asc" ? "▲" : "▼",
31
+ aria_sort: sortable_aria(active, effective_dir)
32
+ }
33
+ end
34
+
35
+ def sortable_aria(active, effective_dir)
36
+ return "none" unless active
37
+
38
+ effective_dir == "asc" ? "ascending" : "descending"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ class PricingOverview
6
+ SOURCES = %i[overrides file bundled].freeze
7
+ RATE_COLUMNS = %i[input output cache_read_input cache_write_input batch_input batch_output].freeze
8
+ Row = Data.define(:provider, :model, :rates)
9
+
10
+ class << self
11
+ def call
12
+ new.call
13
+ end
14
+ end
15
+
16
+ def call
17
+ sources = SOURCES.each_with_object({}) do |source, acc|
18
+ built = build_source(source)
19
+ acc[source] = built if built
20
+ end
21
+ {
22
+ sources: sources,
23
+ effective_source: sources.keys.first || :bundled
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def build_source(source)
30
+ case source
31
+ when :overrides then build_overrides
32
+ when :file then build_file
33
+ when :bundled then build_bundled
34
+ end
35
+ end
36
+
37
+ def build_overrides
38
+ prices = LlmCostTracker.configuration.pricing_overrides
39
+ return nil if prices.nil? || prices.empty?
40
+
41
+ {
42
+ label: "Overrides",
43
+ subtitle: "config.pricing_overrides",
44
+ updated_at: nil,
45
+ currency: nil,
46
+ rows: build_rows(prices)
47
+ }
48
+ end
49
+
50
+ def build_file
51
+ path = LlmCostTracker.configuration.prices_file
52
+ return nil unless path && File.exist?(path)
53
+
54
+ prices = Pricing::Registry.file_prices(path)
55
+ return nil if prices.empty?
56
+
57
+ meta = Pricing::Registry.file_metadata(path)
58
+ {
59
+ label: "Custom file",
60
+ subtitle: path.to_s,
61
+ updated_at: meta["updated_at"] || Pricing::Lookup.prices_file_mtime_iso,
62
+ currency: meta["currency"] || Pricing::Lookup::DEFAULT_CURRENCY,
63
+ rows: build_rows(prices)
64
+ }
65
+ end
66
+
67
+ def build_bundled
68
+ prices = Pricing::Registry.builtin_prices
69
+ meta = Pricing::Registry.metadata
70
+ {
71
+ label: "Bundled",
72
+ subtitle: "ships with the gem",
73
+ updated_at: meta["updated_at"],
74
+ currency: meta["currency"] || Pricing::Lookup::DEFAULT_CURRENCY,
75
+ rows: build_rows(prices)
76
+ }
77
+ end
78
+
79
+ def build_rows(prices)
80
+ rows = prices.map do |key, rates|
81
+ provider, model = split_key(key.to_s)
82
+ Row.new(provider: provider, model: model, rates: rates)
83
+ end
84
+ rows.sort_by { |row| [row.provider || "~", row.model] }
85
+ end
86
+
87
+ def split_key(key)
88
+ provider, model = key.split("/", 2)
89
+ return [provider, model] if model
90
+
91
+ [nil, provider]
92
+ end
93
+ end
94
+ end
95
+ end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/ledger/schema/calls"
4
- require "llm_cost_tracker/ledger/schema/call_line_items"
5
- require "llm_cost_tracker/ledger/schema/call_tags"
6
- require "llm_cost_tracker/ledger/schema/call_rollups"
3
+ require "llm_cost_tracker/ledger"
7
4
 
8
5
  module LlmCostTracker
9
6
  module Dashboard
@@ -12,34 +9,18 @@ module LlmCostTracker
12
9
  DOCS_HINT = "See docs/upgrading.md for the migration path."
13
10
  MUTEX = Mutex.new
14
11
 
15
- CORE_SCHEMA_CHECKS = [
16
- [
17
- LlmCostTracker::Ledger::Schema::Calls,
18
- "The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
19
- ],
20
- [
21
- LlmCostTracker::Ledger::Schema::CallLineItems,
22
- "The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
23
- ],
24
- [
25
- LlmCostTracker::Ledger::Schema::CallTags,
26
- "The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
27
- ]
28
- ].freeze
29
-
30
- OPTIONAL_CALL_ROLLUPS_CHECK = [
31
- LlmCostTracker::Ledger::Schema::CallRollups,
32
- "The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
33
- ].freeze
34
-
35
- private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
12
+ private_constant :MUTEX, :DOCS_HINT
36
13
 
37
14
  class << self
38
15
  def current
39
- return @cached if defined?(@cached)
16
+ fingerprint = schema_fingerprint
40
17
 
41
18
  MUTEX.synchronize do
42
- @cached = compute unless defined?(@cached)
19
+ if !defined?(@cache_fingerprint) || @cache_fingerprint != fingerprint
20
+ LlmCostTracker::Call.reset_column_information
21
+ @cached = compute
22
+ @cache_fingerprint = fingerprint
23
+ end
43
24
  end
44
25
  @cached
45
26
  end
@@ -47,11 +28,23 @@ module LlmCostTracker
47
28
  def reset!
48
29
  MUTEX.synchronize do
49
30
  remove_instance_variable(:@cached) if defined?(@cached)
31
+ remove_instance_variable(:@cache_fingerprint) if defined?(@cache_fingerprint)
50
32
  end
51
33
  end
52
34
 
53
35
  private
54
36
 
37
+ SCHEMA_MIGRATIONS_TABLE = "schema_migrations"
38
+ private_constant :SCHEMA_MIGRATIONS_TABLE
39
+
40
+ def schema_fingerprint
41
+ connection = ActiveRecord::Base.connection
42
+ quoted = connection.quote_table_name(SCHEMA_MIGRATIONS_TABLE)
43
+ connection.query_value("SELECT MAX(version) FROM #{quoted}")
44
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
45
+ nil
46
+ end
47
+
55
48
  def compute
56
49
  LlmCostTracker::Logging.debug("Dashboard::SetupState recomputing")
57
50
  return calls_table_missing unless LlmCostTracker::Call.table_exists?
@@ -64,17 +57,18 @@ module LlmCostTracker
64
57
  end
65
58
 
66
59
  def schema_checks_for_current_config
67
- return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
60
+ return LlmCostTracker::Ledger::Schema::CORE_SCHEMAS unless LlmCostTracker.configuration.cache_rollups
68
61
 
69
- CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
62
+ LlmCostTracker::Ledger::Schema::CORE_SCHEMAS + [LlmCostTracker::Ledger::Schema::CACHE_ROLLUPS_SCHEMA]
70
63
  end
71
64
 
72
65
  def drift_in(checks)
73
- checks.each do |schema, message|
66
+ checks.each do |schema, table|
74
67
  errors = schema.current_schema_errors
75
68
  next if errors.empty?
76
69
 
77
- return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
70
+ message = "The #{table} table does not match the current LLM Cost Tracker schema."
71
+ return SetupRequired.new(message: message, details: errors)
78
72
  end
79
73
  nil
80
74
  end
@@ -85,7 +79,7 @@ module LlmCostTracker
85
79
  unless connection.data_source_exists?(table)
86
80
  return SetupRequired.new(
87
81
  message: "The #{table} table is required when reconciliation is enabled.",
88
- details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
82
+ details: ["bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate"]
89
83
  )
90
84
  end
91
85
 
@@ -93,7 +87,7 @@ module LlmCostTracker
93
87
  next if errors.empty?
94
88
 
95
89
  message = "The #{table} table does not match the current LLM Cost Tracker schema."
96
- return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
90
+ return SetupRequired.new(message: message, details: errors)
97
91
  end
98
92
  nil
99
93
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module Sort
6
+ DIRECTIONS = %w[asc desc].freeze
7
+ end
8
+ end
9
+ end
@@ -4,21 +4,25 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  class TagBreakdown
6
6
  DEFAULT_LIMIT = 100
7
+ SORT_OPTIONS = %w[value calls cost avg_cost].freeze
8
+ DEFAULT_DIRECTIONS = { "value" => "asc", "calls" => "desc", "cost" => "desc", "avg_cost" => "desc" }.freeze
7
9
  Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
8
10
 
9
11
  class << self
10
- def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
11
- new(scope: scope, key: key, limit: limit)
12
+ def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT, sort: "cost", direction: nil)
13
+ new(scope: scope, key: key, limit: limit, sort: sort, direction: direction)
12
14
  end
13
15
  end
14
16
 
15
17
  attr_reader :limit
16
18
 
17
- def initialize(scope:, key:, limit:)
19
+ def initialize(scope:, key:, limit:, sort: "cost", direction: nil)
18
20
  @scope = scope
19
21
  @key = LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
20
22
  limit = limit.to_i
21
23
  @limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
24
+ @sort = SORT_OPTIONS.include?(sort.to_s) ? sort.to_s : "cost"
25
+ @direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
22
26
  end
23
27
 
24
28
  def rows
@@ -51,7 +55,7 @@ module LlmCostTracker
51
55
 
52
56
  private
53
57
 
54
- attr_reader :scope, :key
58
+ attr_reader :scope, :key, :sort, :direction
55
59
 
56
60
  def summary_counts
57
61
  @summary_counts ||= scope.klass.find_by_sql(summary_sql).first
@@ -67,11 +71,21 @@ module LlmCostTracker
67
71
  INNER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
68
72
  WHERE #{tag_present_predicate}
69
73
  GROUP BY #{tag_value_column}
70
- ORDER BY total_cost DESC, calls DESC, value ASC
74
+ ORDER BY #{order_clause}
71
75
  LIMIT #{limit}
72
76
  SQL
73
77
  end
74
78
 
79
+ def order_clause
80
+ dir = direction.upcase
81
+ case sort
82
+ when "value" then "#{tag_value_column} #{dir}"
83
+ when "calls" then "COUNT(*) #{dir}, total_cost DESC"
84
+ when "avg_cost" then "average_cost_per_call #{dir}, total_cost DESC"
85
+ else "total_cost #{dir}, calls DESC, value ASC"
86
+ end
87
+ end
88
+
75
89
  def summary_sql
76
90
  <<~SQL.squish
77
91
  SELECT COUNT(*) AS total_calls,