llm_cost_tracker 0.9.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  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 +16 -4
  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/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -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
 
@@ -22,7 +23,7 @@ module LlmCostTracker
22
23
  private
23
24
 
24
25
  def ensure_current_schema
25
- drift = LlmCostTracker::DashboardSetupState.current
26
+ drift = LlmCostTracker::Dashboard::SetupState.current
26
27
  return unless drift
27
28
 
28
29
  @setup_message = drift.message
@@ -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
@@ -6,24 +6,31 @@ require "json"
6
6
  module LlmCostTracker
7
7
  class CallsController < ApplicationController
8
8
  CSV_EXPORT_LIMIT = 10_000
9
+ CSV_EXPORT_BATCH_SIZE = 500
9
10
  CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
10
- 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
11
17
 
12
18
  def index
13
19
  @sort = params[:sort].to_s
20
+ @dir = params[:dir].to_s
14
21
  scope = Dashboard::Filter.call(params: params)
15
- scope = scope.unknown_pricing if @sort == "unknown_pricing"
16
- 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))
17
24
 
18
25
  respond_to do |format|
19
26
  format.html do
20
27
  @page = Dashboard::Pagination.call(params)
21
- @calls_count = scope.count
28
+ @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
22
29
  @calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
23
30
  end
24
31
  format.csv do
25
32
  response.headers["Cache-Control"] = "no-store"
26
- send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
33
+ send_data render_csv(ordered_scope),
27
34
  type: "text/csv",
28
35
  disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
29
36
  end
@@ -31,23 +38,24 @@ module LlmCostTracker
31
38
  end
32
39
 
33
40
  def show
34
- @call = LlmCostTracker::Call.find(params[:id])
41
+ @call = LlmCostTracker::Call.includes(:line_items, :tag_records).find(params[:id])
35
42
  end
36
43
 
37
44
  private
38
45
 
39
- def calls_order(sort)
40
- case sort
41
- when "expensive"
42
- "CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{DEFAULT_ORDER}"
43
- when "input"
44
- "input_tokens DESC, #{DEFAULT_ORDER}"
45
- when "output"
46
- "output_tokens DESC, #{DEFAULT_ORDER}"
47
- when "slow"
48
- "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
49
- else
50
- 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 }]
51
59
  end
52
60
  end
53
61
 
@@ -55,13 +63,24 @@ module LlmCostTracker
55
63
  fields = csv_fields
56
64
  CSV.generate do |csv|
57
65
  csv << fields.map(&:to_s)
58
-
59
- relation.includes(:tag_records).each do |call|
66
+ each_export_batch(relation) do |call|
60
67
  csv << fields.map { |field| csv_value(field, call) }
61
68
  end
62
69
  end
63
70
  end
64
71
 
72
+ def each_export_batch(relation, &)
73
+ offset = 0
74
+ while offset < CSV_EXPORT_LIMIT
75
+ batch_size = [CSV_EXPORT_BATCH_SIZE, CSV_EXPORT_LIMIT - offset].min
76
+ batch = relation.limit(batch_size).offset(offset).preload(:tag_records).to_a
77
+ break if batch.empty?
78
+
79
+ batch.each(&)
80
+ offset += batch.size
81
+ end
82
+ end
83
+
65
84
  def csv_fields
66
85
  %i[tracked_at provider model] +
67
86
  TokenUsage.members +
@@ -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?
@@ -38,7 +50,7 @@ module LlmCostTracker
38
50
  end
39
51
 
40
52
  def number(value)
41
- number_with_delimiter(value.to_i)
53
+ number_with_delimiter(value)
42
54
  end
43
55
 
44
56
  def format_date(value)
@@ -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
@@ -8,17 +8,22 @@ module LlmCostTracker
8
8
  STATES = [STATE_RUNNING, STATE_COMPLETED, STATE_FAILED].freeze
9
9
 
10
10
  scope :for_source, ->(source) { where(source: source.to_s) }
11
+ scope :for_provider, ->(provider) { where(provider: provider.to_s) }
11
12
  scope :running, -> { where(state: STATE_RUNNING) }
12
13
  scope :completed, -> { where(state: STATE_COMPLETED) }
13
14
  scope :failed, -> { where(state: STATE_FAILED) }
14
15
  scope :latest, -> { order(started_at: :desc, id: :desc) }
15
16
 
16
- def self.resume_cursor_for(source)
17
- for_source(source).latest.limit(1).pick(:cursor)
17
+ def self.resume_cursor_for(source, provider: nil)
18
+ scope = for_source(source)
19
+ scope = scope.for_provider(provider) if provider
20
+ scope.latest.limit(1).pick(:cursor)
18
21
  end
19
22
 
20
- def self.last_completed_window_for(source)
21
- for_source(source).completed.latest.limit(1).pick(:window_start, :window_end)
23
+ def self.last_completed_window_for(source, provider: nil)
24
+ scope = for_source(source)
25
+ scope = scope.for_provider(provider) if provider
26
+ scope.completed.latest.limit(1).pick(:window_start, :window_end)
22
27
  end
23
28
  end
24
29
  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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm_cost_tracker/ledger"
4
+
5
+ module LlmCostTracker
6
+ module Dashboard
7
+ module SetupState
8
+ SetupRequired = Data.define(:message, :details)
9
+ DOCS_HINT = "See docs/upgrading.md for the migration path."
10
+ MUTEX = Mutex.new
11
+
12
+ private_constant :MUTEX, :DOCS_HINT
13
+
14
+ class << self
15
+ def current
16
+ fingerprint = schema_fingerprint
17
+
18
+ MUTEX.synchronize do
19
+ if !defined?(@cache_fingerprint) || @cache_fingerprint != fingerprint
20
+ LlmCostTracker::Call.reset_column_information
21
+ @cached = compute
22
+ @cache_fingerprint = fingerprint
23
+ end
24
+ end
25
+ @cached
26
+ end
27
+
28
+ def reset!
29
+ MUTEX.synchronize do
30
+ remove_instance_variable(:@cached) if defined?(@cached)
31
+ remove_instance_variable(:@cache_fingerprint) if defined?(@cache_fingerprint)
32
+ end
33
+ end
34
+
35
+ private
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
+
48
+ def compute
49
+ LlmCostTracker::Logging.debug("Dashboard::SetupState recomputing")
50
+ return calls_table_missing unless LlmCostTracker::Call.table_exists?
51
+
52
+ core_drift = drift_in(schema_checks_for_current_config)
53
+ return core_drift if core_drift
54
+ return nil unless LlmCostTracker.reconciliation_enabled?
55
+
56
+ reconciliation_drift
57
+ end
58
+
59
+ def schema_checks_for_current_config
60
+ return LlmCostTracker::Ledger::Schema::CORE_SCHEMAS unless LlmCostTracker.configuration.cache_rollups
61
+
62
+ LlmCostTracker::Ledger::Schema::CORE_SCHEMAS + [LlmCostTracker::Ledger::Schema::CACHE_ROLLUPS_SCHEMA]
63
+ end
64
+
65
+ def drift_in(checks)
66
+ checks.each do |schema, table|
67
+ errors = schema.current_schema_errors
68
+ next if errors.empty?
69
+
70
+ message = "The #{table} table does not match the current LLM Cost Tracker schema."
71
+ return SetupRequired.new(message: message, details: errors)
72
+ end
73
+ nil
74
+ end
75
+
76
+ def reconciliation_drift
77
+ connection = ActiveRecord::Base.connection
78
+ LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
79
+ unless connection.data_source_exists?(table)
80
+ return SetupRequired.new(
81
+ message: "The #{table} table is required when reconciliation is enabled.",
82
+ details: ["bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate"]
83
+ )
84
+ end
85
+
86
+ errors = schema.current_schema_errors
87
+ next if errors.empty?
88
+
89
+ message = "The #{table} table does not match the current LLM Cost Tracker schema."
90
+ return SetupRequired.new(message: message, details: errors)
91
+ end
92
+ nil
93
+ end
94
+
95
+ def calls_table_missing
96
+ SetupRequired.new(
97
+ message: "The llm_cost_tracker_calls table is not available yet.",
98
+ details: nil
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
104
+ 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