llm_cost_tracker 0.14.2 → 0.15.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ class SpendChart
6
+ WIDTH = 1180
7
+ PADDING = { top: 16, right: 16, bottom: 28, left: 56 }.freeze
8
+ MIN_MAX_COST = 0.0001
9
+
10
+ attr_reader :points, :height, :y_ticks, :max_cost, :coords, :comparison_coords
11
+
12
+ def initialize(points, comparison_points: nil, height: 180, y_ticks: 3)
13
+ @points = points
14
+ @height = height
15
+ @y_ticks = y_ticks
16
+ costs = points.map { |point| point[:cost].to_f } + Array(comparison_points).map { |point| point[:cost].to_f }
17
+ @max_cost = [costs.max.to_f, MIN_MAX_COST].max
18
+ @coords = coords_for(points)
19
+ @comparison_coords = coords_for(comparison_points) if comparison_points.present?
20
+ end
21
+
22
+ def peak_index
23
+ @peak_index ||= points.each_with_index.max_by { |point, _| point[:cost].to_f }.last
24
+ end
25
+
26
+ def left = PADDING[:left]
27
+ def right = left + plot_width
28
+ def baseline = PADDING[:top] + plot_height
29
+ def tick_y(index) = PADDING[:top] + (plot_height * index.to_f / y_ticks)
30
+ def tick_value(index) = max_cost * (y_ticks - index).to_f / y_ticks
31
+
32
+ def label_indexes
33
+ count = points.size
34
+ count <= 2 ? (0...count).to_a : [0, count / 2, count - 1].uniq
35
+ end
36
+
37
+ private
38
+
39
+ def plot_width = WIDTH - PADDING[:left] - PADDING[:right]
40
+ def plot_height = height - PADDING[:top] - PADDING[:bottom]
41
+
42
+ def coords_for(series)
43
+ step = series.size > 1 ? plot_width.to_f / (series.size - 1) : 0.0
44
+ series.each_with_index.map do |point, index|
45
+ [left + (index * step), baseline - ((point[:cost].to_f / max_cost) * plot_height)]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -20,8 +20,7 @@ module LlmCostTracker
20
20
  @key = LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
21
21
  limit = limit.to_i
22
22
  @limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
23
- @sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : "cost"
24
- @direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
23
+ @order = Sort.resolve(sort, direction, natural_directions: DEFAULT_DIRECTIONS, fallback: "cost")
25
24
  end
26
25
 
27
26
  def rows
@@ -34,7 +33,7 @@ module LlmCostTracker
34
33
  calls: calls,
35
34
  total_cost: row.total_cost,
36
35
  average_cost_per_call: row.average_cost_per_call,
37
- share_percent: total.positive? ? (calls.to_f / total) * 100.0 : 0.0
36
+ share_percent: Percent.of(calls, total)
38
37
  )
39
38
  end
40
39
  end
@@ -54,7 +53,7 @@ module LlmCostTracker
54
53
 
55
54
  private
56
55
 
57
- attr_reader :scope, :key, :sort, :direction
56
+ attr_reader :scope, :key, :order
58
57
 
59
58
  def summary_counts
60
59
  @summary_counts ||= scope.klass.find_by_sql(summary_sql).first
@@ -76,8 +75,8 @@ module LlmCostTracker
76
75
  end
77
76
 
78
77
  def order_clause
79
- dir = direction.upcase
80
- case sort
78
+ dir = order.direction.upcase
79
+ case order.column
81
80
  when "value" then "#{tag_value_column} #{dir}"
82
81
  when "calls" then "COUNT(*) #{dir}, total_cost DESC"
83
82
  when "avg_cost" then "average_cost_per_call #{dir}, total_cost DESC"
@@ -108,11 +107,11 @@ module LlmCostTracker
108
107
  end
109
108
 
110
109
  def quote_column(name)
111
- scope.connection.quote_column_name(name)
110
+ scope.lease_connection.quote_column_name(name)
112
111
  end
113
112
 
114
113
  def quoted_key
115
- scope.connection.quote(key)
114
+ scope.lease_connection.quote(key)
116
115
  end
117
116
  end
118
117
  end
@@ -13,16 +13,13 @@ module LlmCostTracker
13
13
 
14
14
  def initialize(scope:, limit:)
15
15
  @scope = scope
16
- @connection = LlmCostTracker::Call.connection
16
+ @connection = LlmCostTracker::Call.lease_connection
17
17
  limit = limit.to_i
18
18
  @limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
19
19
  end
20
20
 
21
21
  def rows
22
22
  scope.klass.find_by_sql(build_sql)
23
- rescue ActiveRecord::StatementInvalid => e
24
- LlmCostTracker::Logging.warn("Tag key discovery failed (#{connection.adapter_name}): #{e.class}: #{e.message}")
25
- []
26
23
  end
27
24
 
28
25
  private
@@ -42,21 +42,20 @@ module LlmCostTracker
42
42
  def initialize(scope:, limit:, sort: DEFAULT_SORT, direction: nil)
43
43
  @scope = scope
44
44
  @limit = limit
45
- @sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
46
- @direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
45
+ @order = Sort.resolve(sort, direction, natural_directions: DEFAULT_DIRECTIONS, fallback: DEFAULT_SORT)
47
46
  end
48
47
 
49
48
  def rows
50
49
  scope
51
50
  .group(:provider, :model)
52
51
  .select(selects)
53
- .order(*ORDER_NODES.fetch([sort, direction]))
52
+ .order(*ORDER_NODES.fetch([order.column, order.direction]))
54
53
  .then { |r| limit ? r.limit(limit) : r }
55
54
  end
56
55
 
57
56
  private
58
57
 
59
- attr_reader :scope, :limit, :sort, :direction
58
+ attr_reader :scope, :limit, :order
60
59
 
61
60
  def selects
62
61
  columns = [
@@ -54,28 +54,28 @@
54
54
  <aside class="lct-sidebar" aria-label="Dashboard">
55
55
  <div class="lct-sidebar-inner">
56
56
  <div class="lct-sidebar-section">Insights</div>
57
- <%= link_to root_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :overview) || "lct-sidebar-link", aria: (dashboard_section == :overview ? { current: "page" } : {}) do %>
57
+ <%= link_to root_path, sidebar_link_options(:overview) do %>
58
58
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
59
59
  Overview
60
60
  <% end %>
61
- <%= link_to models_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :models) || "lct-sidebar-link", aria: (dashboard_section == :models ? { current: "page" } : {}) do %>
61
+ <%= link_to models_path, sidebar_link_options(:models) do %>
62
62
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
63
63
  Models
64
64
  <% end %>
65
- <%= link_to calls_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :calls) || "lct-sidebar-link", aria: (dashboard_section == :calls ? { current: "page" } : {}) do %>
65
+ <%= link_to calls_path, sidebar_link_options(:calls) do %>
66
66
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
67
67
  Calls
68
68
  <% end %>
69
- <%= link_to tags_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :tags) || "lct-sidebar-link", aria: (dashboard_section == :tags ? { current: "page" } : {}) do %>
69
+ <%= link_to tags_path, sidebar_link_options(:tags) do %>
70
70
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
71
71
  Tags
72
72
  <% end %>
73
- <%= link_to data_quality_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :data_quality) || "lct-sidebar-link", aria: (dashboard_section == :data_quality ? { current: "page" } : {}) do %>
73
+ <%= link_to data_quality_path, sidebar_link_options(:data_quality) do %>
74
74
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
75
75
  Data Quality
76
76
  <% end %>
77
77
  <div class="lct-sidebar-section">Reference</div>
78
- <%= link_to pricing_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :pricing) || "lct-sidebar-link", aria: (dashboard_section == :pricing ? { current: "page" } : {}) do %>
78
+ <%= link_to pricing_path, sidebar_link_options(:pricing) do %>
79
79
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
80
80
  Pricing
81
81
  <% end %>
@@ -19,7 +19,8 @@
19
19
  <% end %>
20
20
 
21
21
  <span class="lct-filter-row-meta"><%= number_with_delimiter(@calls_count) %> call<%= "s" unless @calls_count == 1 %> · <%= money(@calls_total_cost) %></span>
22
- <%= link_to "Export CSV", calls_path(current_query(format: :csv)), class: "lct-page-link", title: "Capped at #{number_with_delimiter(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT)} rows per request" %>
22
+ <% export_limit = LlmCostTracker::CallsController::CSV_EXPORT_LIMIT %>
23
+ <%= link_to(@calls_count > export_limit ? "Export CSV (first #{number_with_delimiter(export_limit)})" : "Export CSV", calls_path(current_query(format: :csv)), class: "lct-page-link", title: "Capped at #{number_with_delimiter(export_limit)} rows per request") %>
23
24
  </div>
24
25
 
25
26
  <% if @calls_count.zero? %>
@@ -1,15 +1,4 @@
1
- <% priced_components = token_usage_stack_components %>
2
- <% line_item_costs_by_component = call_line_item_costs_by_component(@call) %>
3
- <% token_segments = priced_components.map do |component|
4
- token_key = component.fetch(:token_key)
5
- value = @call[token_key]
6
- { label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
7
- end %>
8
- <% cost_segments = priced_components.map do |component|
9
- value = line_item_costs_by_component[component.fetch(:price_key)]
10
- { label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
11
- end %>
12
- <% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
1
+ <% service_line_items = call_service_line_items(@call) %>
13
2
 
14
3
  <p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
15
4
  <h2 class="lct-page-title"><code class="lct-code-id"><%= @call.model %></code> <span class="lct-page-title-meta">#<%= @call.id %></span></h2>
@@ -42,7 +31,7 @@ end %>
42
31
  <section class="lct-panel">
43
32
  <div class="lct-panel-head"><h2 class="lct-panel-title">Token mix</h2></div>
44
33
  <div class="lct-panel-body">
45
- <%= render "llm_cost_tracker/shared/metric_stack", segments: token_segments, empty_message: "No token data for this call." %>
34
+ <%= render "llm_cost_tracker/shared/metric_stack", segments: call_token_segments(@call), empty_message: "No token data for this call." %>
46
35
  </div>
47
36
  </section>
48
37
 
@@ -52,7 +41,7 @@ end %>
52
41
  <% if @call.total_cost.nil? %>
53
42
  <p class="lct-stack-empty">Pricing not available for this call.</p>
54
43
  <% else %>
55
- <%= render "llm_cost_tracker/shared/metric_stack", segments: cost_segments, empty_message: "No cost breakdown for this call." %>
44
+ <%= render "llm_cost_tracker/shared/metric_stack", segments: call_cost_segments(@call), empty_message: "No cost breakdown for this call." %>
56
45
  <% end %>
57
46
  </div>
58
47
  </section>
@@ -0,0 +1,6 @@
1
+ <section class="lct-panel lct-empty">
2
+ <h2 class="lct-state-title">Query failed</h2>
3
+ <p class="lct-state-copy">
4
+ A dashboard query failed, for example on a statement timeout or a lock. The error is in your application log.
5
+ </p>
6
+ </section>
@@ -1,5 +1,5 @@
1
1
  <% active = params[:stream].presence %>
2
- <% display_value = case active when "yes" then "Streaming" when "no" then "Non-streaming" else "All" end %>
2
+ <% display_value = LlmCostTracker::Dashboard::Filter::STREAM_FILTER_LABELS.fetch(active, "All") %>
3
3
  <details class="lct-filter-pop" name="lct-filter">
4
4
  <summary class="lct-filter-pill <%= 'lct-active' if active %>">
5
5
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
@@ -1,6 +1,6 @@
1
1
  <% form_url = tag_path(params[:key]) %>
2
2
 
3
- <% if @value.present? %>
3
+ <% if @breakdown.nil? %>
4
4
  <p class="lct-breadcrumb-back"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
5
5
  <h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code> = <code class="lct-code-id"><%= @value %></code></h2>
6
6
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "../pricing/estimator"
6
+
7
+ module LlmCostTracker
8
+ module Budget
9
+ Estimate = Data.define(:total, :largest) do
10
+ def self.for(provider:, model:, request:)
11
+ costs = request_costs(provider, model, request)
12
+ total = costs.sum(BigDecimal("0"))
13
+ new(total: total, largest: costs.max || total)
14
+ end
15
+
16
+ def self.request_costs(provider, model, request)
17
+ batch = request["requests"] if request && !model
18
+ return [cost(provider, model, request)] unless batch.is_a?(Array)
19
+
20
+ batch.map do |entry|
21
+ params = entry["params"] if entry.is_a?(Hash)
22
+ params.is_a?(Hash) ? cost(provider, params["model"], params) : 0
23
+ end
24
+ end
25
+
26
+ def self.cost(provider, model, request)
27
+ return BigDecimal("0") unless provider && model && request
28
+
29
+ Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
30
+ end
31
+
32
+ private_class_method :request_costs, :cost
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "limit"
6
+ require_relative "../ledger"
7
+
8
+ module LlmCostTracker
9
+ module Budget
10
+ module Global
11
+ PERIODS = { monthly: :month, daily: :day }.freeze
12
+
13
+ class << self
14
+ def enforce!(estimate, time:)
15
+ budgets = LlmCostTracker.configuration.budgets
16
+ per_call = budgets.per_call
17
+ largest = estimate.largest
18
+ Limit.global(:per_call, per_call).block!(largest) if per_call && largest.positive? && largest >= per_call
19
+
20
+ windows = { monthly: budgets.monthly, daily: budgets.daily }
21
+ each_exceeded(windows, time: time, estimate: estimate.total) { |limit, total| limit.block!(total) }
22
+ end
23
+
24
+ def post_spend_errors(event, behavior_override)
25
+ budgets = LlmCostTracker.configuration.budgets
26
+ errors = [per_call_error(event, budgets.per_call, behavior_override)]
27
+ each_exceeded({ daily: budgets.daily, monthly: budgets.monthly }, time: event.tracked_at) do |limit, total|
28
+ errors << limit.handle_exceeded(total: total,
29
+ previous_total: total - event.total_cost,
30
+ last_event: event,
31
+ behavior_override: behavior_override)
32
+ end
33
+ errors
34
+ end
35
+
36
+ def notify_repriced(changes, time:)
37
+ budgets = LlmCostTracker.configuration.budgets
38
+ each_exceeded({ daily: budgets.daily, monthly: budgets.monthly }, time: time) do |limit, total|
39
+ limit.handle_exceeded(total: total,
40
+ previous_total: total - limit.amount_in_window(changes, time),
41
+ behavior_override: :notify)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def per_call_error(event, per_call, behavior_override)
48
+ total = event.total_cost
49
+ return unless per_call && total >= per_call
50
+
51
+ Limit.global(:per_call, per_call).handle_exceeded(
52
+ total: total, previous_total: nil, last_event: event, behavior_override: behavior_override
53
+ )
54
+ end
55
+
56
+ def each_exceeded(windows, time:, estimate: BigDecimal("0"))
57
+ windows = windows.compact
58
+ return if windows.empty?
59
+
60
+ totals = Ledger::Period::Totals.call(windows.keys.map { |type| PERIODS.fetch(type) }, time: time)
61
+ windows.each do |type, budget|
62
+ total = totals.fetch(PERIODS.fetch(type)) + estimate
63
+ yield Limit.global(type, budget), total if total >= budget
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module LlmCostTracker
6
+ module Budget
7
+ Limit = Data.define(:budget_type, :budget, :scope, :behavior, :on_exceeded) do
8
+ def self.global(budget_type, budget)
9
+ new(budget_type: budget_type, budget: budget, scope: nil, behavior: nil, on_exceeded: nil)
10
+ end
11
+
12
+ def over?(total)
13
+ budget_type == :calls ? total > budget : total >= budget
14
+ end
15
+
16
+ def charge(cost)
17
+ budget_type == :calls ? 1 : cost
18
+ end
19
+
20
+ def counts?(event)
21
+ budget_type == :calls || !event.total_cost.nil?
22
+ end
23
+
24
+ def amount_in_window(changes, time)
25
+ start = PerTag.window_start(budget_type, time)
26
+ changes.sum(BigDecimal("0")) { |change| start.nil? || change.tracked_at >= start ? change.total_cost : 0 }
27
+ end
28
+
29
+ def block!(total)
30
+ raise BudgetExceededError.new(
31
+ budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
32
+ )
33
+ end
34
+
35
+ def handle_exceeded(total:, previous_total:, last_event: nil, behavior_override: nil)
36
+ budgets = LlmCostTracker.configuration.budgets
37
+ raising = %i[raise block_requests].include?(behavior_override || behavior || budgets.exceeded_behavior)
38
+ callback = on_exceeded || budgets.on_exceeded
39
+ payload = {
40
+ budget_type: budget_type,
41
+ total: total,
42
+ budget: budget,
43
+ last_event: last_event,
44
+ stage: :post_spend,
45
+ scope: scope
46
+ }
47
+ callback.call(payload) if callback && (previous_total.nil? || !over?(previous_total))
48
+ BudgetExceededError.new(**payload) if raising
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "limit"
4
+ require_relative "../timing"
3
5
  require_relative "../ledger/isolation"
4
6
  require_relative "../ledger/schema/adapter"
5
7
  require_relative "../ledger/tags/encoding"
@@ -14,7 +16,15 @@ module LlmCostTracker
14
16
  SLOW_READ_SECONDS = 0.1
15
17
  DEFAULT_BACKFILL_BATCH = 5_000
16
18
 
17
- Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
19
+ Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded) do
20
+ def limit(window)
21
+ Limit.new(budget_type: window,
22
+ budget: windows.fetch(window),
23
+ scope: { key: key, value: value },
24
+ behavior: behavior,
25
+ on_exceeded: on_exceeded)
26
+ end
27
+ end
18
28
 
19
29
  class << self
20
30
  def configured
@@ -53,45 +63,42 @@ module LlmCostTracker
53
63
  end
54
64
 
55
65
  def rules_for_events(events)
56
- events.select(&:total_cost).each_with_object({}) do |event, grouped|
66
+ events.each_with_object({}) do |event, grouped|
57
67
  rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
58
68
  end
59
69
  end
60
70
 
61
71
  def spend(key, value, window, time:)
62
- spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first.to_d
72
+ spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first
63
73
  end
64
74
 
65
75
  def spend_by_value(key, values, window, bucket, upto = nil)
66
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
- range = window_range(window, bucket)
68
- upto_sum = LlmCostTracker::CallTag.sanitize_sql_array(
69
- ["SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{COST_COLUMN} ELSE 0 END)", upto || range.end]
70
- )
76
+ started_at = Timing.now_monotonic
77
+ aggregates = sum_columns(window, upto)
71
78
  totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
72
- LlmCostTracker::CallTag
73
- .where(key: key, value: values, TIME_COLUMN => range)
74
- .group(:value)
75
- .pluck(:value, Arel.sql("SUM(#{COST_COLUMN})"), Arel.sql(upto_sum))
76
- .to_h { |value, *sums| [value, sums.map(&:to_d)] }
79
+ pluck_by_exact_value(spend_rows(key, values, window, bucket), values, aggregates).to_h do |value, *sums|
80
+ [value.dup.force_encoding(Encoding::UTF_8), sums.map { |sum| window == :calls ? sum.to_i : sum.to_d }]
81
+ end
77
82
  end
78
- warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
83
+ warn_slow_read(key, window, Timing.now_monotonic - started_at)
79
84
  totals
80
85
  end
81
86
 
82
87
  def window_start(window, time)
83
- time.to_time.utc.public_send(WINDOW_STARTS.fetch(window))
88
+ WINDOW_STARTS[window]&.then { |start| time.to_time.utc.public_send(start) }
84
89
  end
85
90
 
86
91
  def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
87
92
  return 0 unless columns?
88
93
 
89
94
  filled = 0
95
+ after = 0
90
96
  loop do
91
- copied = copy_next_batch(batch_size)
92
- break if copied.zero?
97
+ ids = unfilled_ids(after, batch_size)
98
+ break if ids.empty?
93
99
 
94
- filled += copied
100
+ filled += LlmCostTracker::CallTag.lease_connection.update(copy_sql(ids))
101
+ after = ids.last
95
102
  end
96
103
  filled
97
104
  end
@@ -103,22 +110,42 @@ module LlmCostTracker
103
110
 
104
111
  private
105
112
 
113
+ def sum_columns(window, upto)
114
+ measure = window == :calls ? "1" : COST_COLUMN
115
+ total = "SUM(#{measure})"
116
+ upto_sum = "SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{measure} ELSE 0 END)"
117
+ upto_total = upto ? LlmCostTracker::CallTag.sanitize_sql_array([upto_sum, upto]) : total
118
+ [Arel.sql(total), Arel.sql(upto_total)]
119
+ end
120
+
121
+ def spend_rows(key, values, window, bucket)
122
+ rows = LlmCostTracker::CallTag.where(key: key, value: values)
123
+ bucket ? rows.where(TIME_COLUMN => window_range(window, bucket)) : rows.where.not(TIME_COLUMN => nil)
124
+ end
125
+
126
+ def pluck_by_exact_value(rows, values, aggregates)
127
+ column = :value
128
+ if Ledger::Schema::Adapter.mysql?(LlmCostTracker::CallTag.lease_connection)
129
+ column = Arel.sql("CAST(value AS BINARY)")
130
+ rows = rows.where(column.in(values))
131
+ end
132
+ rows.group(column).pluck(column, *aggregates)
133
+ end
134
+
106
135
  def window_range(window, bucket)
107
136
  bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
108
137
  end
109
138
 
110
- def copy_next_batch(batch_size)
111
- ids = LlmCostTracker::CallTag.where(TIME_COLUMN => nil).limit(batch_size).pluck(:id)
112
- return 0 if ids.empty?
113
-
114
- LlmCostTracker::CallTag.connection.update(copy_sql(ids))
139
+ def unfilled_ids(after, batch_size)
140
+ LlmCostTracker::CallTag.where(TIME_COLUMN => nil).where(id: (after + 1)..)
141
+ .order(:id).limit(batch_size).pluck(:id)
115
142
  end
116
143
 
117
144
  def copy_sql(ids)
118
145
  tags = LlmCostTracker::CallTag.quoted_table_name
119
146
  calls = LlmCostTracker::Call.quoted_table_name
120
147
  list = ids.join(",")
121
- return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.connection)
148
+ return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.lease_connection)
122
149
  UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
123
150
  SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
124
151
  WHERE t.id IN (#{list})
@@ -146,10 +173,15 @@ module LlmCostTracker
146
173
  @slow_keys ||= Set.new
147
174
  return unless @slow_keys.add?(key)
148
175
 
176
+ reason = if WINDOW_STARTS.key?(window)
177
+ "A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
178
+ "an index effectively. Budget high-cardinality tags such as a tenant or user id."
179
+ else
180
+ "A #{window} limit sums every call the value has recorded, so it suits short-lived values " \
181
+ "such as a run."
182
+ end
149
183
  Logging.warn(
150
- "config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. " \
151
- "A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
152
- "an index effectively. Budget high-cardinality tags such as a tenant or user id."
184
+ "config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. #{reason}"
153
185
  )
154
186
  end
155
187
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "per_tag"
4
+ require_relative "../tags/context"
5
+
6
+ module LlmCostTracker
7
+ module Budget
8
+ module Tagged
9
+ class << self
10
+ def enforce!(tags, estimated_cost, time:, blocking_only:)
11
+ PerTag.rules_for(tags || Tags::Context.tags, blocking_only: blocking_only).each do |rule|
12
+ rule.windows.each_key do |window|
13
+ limit = rule.limit(window)
14
+ total = PerTag.spend(rule.key, rule.value, window, time: time) + limit.charge(estimated_cost)
15
+ limit.block!(total) if limit.over?(total)
16
+ end
17
+ end
18
+ end
19
+
20
+ def post_spend_errors(events, behavior_override)
21
+ window_buckets(scored_rules(events, behavior_override)).flat_map do |(key, window, bucket), recorded_by_rule|
22
+ upto = recorded_by_rule.values.flatten.map(&:tracked_at).max unless Ingestion.async?
23
+ totals = PerTag.spend_by_value(key, recorded_by_rule.keys.map(&:value), window, bucket, upto)
24
+ recorded_by_rule.filter_map do |rule, recorded|
25
+ total, total_through_batch = totals.fetch(rule.value, [0, 0])
26
+ bucket_error(rule.limit(window), recorded, total, total_through_batch, behavior_override)
27
+ end
28
+ end
29
+ end
30
+
31
+ def notify_repriced(changes, time:)
32
+ PerTag.rules_for_events(changes).each do |rule, repriced|
33
+ rule.windows.except(:calls).each_key do |window|
34
+ limit = rule.limit(window)
35
+ amount = limit.amount_in_window(repriced, time)
36
+ total = PerTag.spend(rule.key, rule.value, window, time: time) if rule.on_exceeded && amount.positive?
37
+ next unless total && limit.over?(total)
38
+
39
+ limit.handle_exceeded(total: total, previous_total: total - amount, behavior_override: :notify)
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def scored_rules(events, behavior_override)
47
+ by_rule = PerTag.rules_for_events(events)
48
+ behavior_override == :notify ? by_rule.reject { |rule, _| rule.on_exceeded.nil? } : by_rule
49
+ end
50
+
51
+ def bucket_error(limit, recorded, total, total_through_batch, behavior_override)
52
+ return unless limit.over?(total)
53
+
54
+ reference = limit.over?(total_through_batch) ? total_through_batch : total
55
+ limit.handle_exceeded(total: total,
56
+ previous_total: reference - recorded.sum { |event| limit.charge(event.total_cost) },
57
+ last_event: recorded.last,
58
+ behavior_override: behavior_override)
59
+ end
60
+
61
+ def window_buckets(by_rule)
62
+ by_rule.each_with_object({}) do |(rule, events), grouped|
63
+ rule.windows.each_key do |window|
64
+ limit = rule.limit(window)
65
+ events.select { |event| limit.counts?(event) }
66
+ .group_by { |event| PerTag.window_start(window, event.tracked_at) }
67
+ .each { |bucket, bucket_events| (grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end