llm_cost_tracker 0.14.1 → 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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -11,6 +11,7 @@ module LlmCostTracker
11
11
  ["Streaming only", "yes"],
12
12
  ["Non-streaming only", "no"]
13
13
  ].freeze
14
+ STREAM_FILTER_LABELS = { "yes" => "Streaming", "no" => "Non-streaming" }.freeze
14
15
 
15
16
  class << self
16
17
  def call(scope: LlmCostTracker::Call.all, params: {}, tags: {})
@@ -39,17 +40,13 @@ module LlmCostTracker
39
40
  attr_reader :scope, :params, :extra_tags
40
41
 
41
42
  def apply_date_filters(relation)
42
- from_date = Dashboard::DateRange.parse(params, :from)
43
- to_date = Dashboard::DateRange.parse(params, :to)
44
- Dashboard::DateRange.validate!(from: from_date, to: to_date)
45
-
46
- default_range = Dashboard::DateRange.call(params: params)
47
- from_date ||= default_range.from
48
- to_date ||= default_range.to
43
+ Dashboard::DateRange.validate!(from: Dashboard::DateRange.parse(params, :from),
44
+ to: Dashboard::DateRange.parse(params, :to))
45
+ range = Dashboard::DateRange.call(params: params)
49
46
 
50
47
  relation
51
- .where(tracked_at: from_date.beginning_of_day..)
52
- .where(tracked_at: ..to_date.end_of_day)
48
+ .where(tracked_at: range.from.beginning_of_day..)
49
+ .where(tracked_at: ..range.to.end_of_day)
53
50
  end
54
51
 
55
52
  def apply_exact_filter(relation, key)
@@ -94,7 +91,7 @@ module LlmCostTracker
94
91
 
95
92
  def matchable?(values)
96
93
  values.none? { |value| value.include?("\0") } ||
97
- !LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
94
+ !LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.lease_connection)
98
95
  end
99
96
 
100
97
  def normalized_string(value, name)
@@ -2,29 +2,22 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Dashboard
5
- module MonthlyBudget
6
- module_function
7
-
8
- def status
5
+ class MonthlyBudget
6
+ def self.status
9
7
  budget = LlmCostTracker.configuration.budgets.monthly
10
8
  return nil unless budget
11
9
 
12
- budget = budget.to_f
13
- now = Time.now.utc
14
- month_start = now.beginning_of_month
15
- month_end = now.end_of_month
16
- spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
17
- elapsed_seconds = now - month_start
18
- total_seconds = month_end - month_start
19
- projected_spent = if spent.zero? || !elapsed_seconds.positive?
20
- spent
21
- else
22
- spent * (total_seconds / elapsed_seconds)
23
- end
24
- percent_used = budget.positive? ? (spent / budget) * 100.0 : 0.0
25
- projected_percent_used = budget.positive? ? (projected_spent / budget) * 100.0 : 0.0
26
- projected_delta = projected_spent - budget
10
+ new(budget.to_f, Time.now.utc).status
11
+ end
27
12
 
13
+ def initialize(budget, now)
14
+ @budget = budget
15
+ @now = now
16
+ @spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
17
+ @projected_spent = project(@spent)
18
+ end
19
+
20
+ def status
28
21
  {
29
22
  budget: budget,
30
23
  spent: spent,
@@ -32,31 +25,45 @@ module LlmCostTracker
32
25
  projected_spent: projected_spent,
33
26
  projected_percent_used: projected_percent_used,
34
27
  projected_delta: projected_delta,
35
- projection_end_label: month_end.strftime("%b %-d"),
36
- fill_modifier: fill_modifier(percent_used),
37
- progress_percent: clamped_percent(percent_used),
38
- projected_marker_percent: clamped_percent(projected_percent_used),
39
- projected_delta_amount: projected_delta.abs,
40
- projected_delta_direction: projected_delta.positive? ? "over" : "under",
41
- projected_delta_status_class: projected_delta_status_class(projected_delta)
28
+ projection_end_label: now.end_of_month.strftime("%b %-d"),
29
+ fill_modifier: fill_modifier,
30
+ progress_percent: percent_used.clamp(0.0, 100.0),
31
+ projected_marker_percent: projected_percent_used.clamp(0.0, 100.0),
32
+ **projected_delta_labels
42
33
  }
43
34
  end
44
35
 
45
- def clamped_percent(value)
46
- value.clamp(0.0, 100.0)
36
+ private
37
+
38
+ attr_reader :budget, :now, :spent, :projected_spent
39
+
40
+ def project(amount)
41
+ month_start = now.beginning_of_month
42
+ elapsed_seconds = now - month_start
43
+ return amount if amount.zero? || !elapsed_seconds.positive?
44
+
45
+ amount * ((now.end_of_month - month_start) / elapsed_seconds)
47
46
  end
48
47
 
49
- def fill_modifier(percent)
50
- return "lct-budget-fill--over" if percent >= 100.0
51
- return "lct-budget-fill--warn" if percent >= 80.0
48
+ def percent_used = share_of_budget(spent)
49
+ def projected_percent_used = share_of_budget(projected_spent)
50
+ def share_of_budget(amount) = budget.positive? ? (amount / budget) * 100.0 : 0.0
51
+ def projected_delta = projected_spent - budget
52
+
53
+ def fill_modifier
54
+ return "lct-budget-fill--over" if percent_used >= 100.0
55
+ return "lct-budget-fill--warn" if percent_used >= 80.0
52
56
 
53
57
  ""
54
58
  end
55
59
 
56
- def projected_delta_status_class(delta)
57
- return "lct-budget-projection-status--over" if delta.positive?
58
-
59
- "lct-budget-projection-status--under"
60
+ def projected_delta_labels
61
+ direction = projected_delta.positive? ? "over" : "under"
62
+ {
63
+ projected_delta_amount: projected_delta.abs,
64
+ projected_delta_direction: direction,
65
+ projected_delta_status_class: "lct-budget-projection-status--#{direction}"
66
+ }
60
67
  end
61
68
  end
62
69
  end
@@ -19,11 +19,8 @@ module LlmCostTracker
19
19
  )
20
20
  end
21
21
 
22
- def self.integer_param(params, key, default:, min:, max: nil)
23
- value = Integer(params[key], 10)
24
- value = [value, min].max
25
- value = [value, max].min if max
26
- value
22
+ def self.integer_param(params, key, default:, min:, max:)
23
+ Integer(params[key], 10).clamp(min, max)
27
24
  rescue ArgumentError, TypeError
28
25
  default
29
26
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module Percent
6
+ def self.of(part, whole)
7
+ whole = whole.to_f
8
+ whole.positive? ? (part.to_f / whole) * 100.0 : 0.0
9
+ end
10
+ end
11
+ end
12
+ end
@@ -63,9 +63,10 @@ module LlmCostTracker
63
63
  end
64
64
 
65
65
  def build_rows(prices)
66
+ today = Time.now.utc.to_date.iso8601
66
67
  rows = prices.map do |key, rates|
67
68
  provider, model = split_key(key.to_s)
68
- Row.new(provider: provider, model: model, rates: rates)
69
+ Row.new(provider: provider, model: model, rates: Pricing::Matcher.prices_on(rates, today))
69
70
  end
70
71
  rows.sort_by { |row| [row.provider || "~", row.model] }
71
72
  end
@@ -4,6 +4,15 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  module Sort
6
6
  DIRECTIONS = %w[asc desc].freeze
7
+
8
+ Choice = Data.define(:column, :direction)
9
+
10
+ def self.resolve(column, direction, natural_directions:, fallback:)
11
+ column = natural_directions.key?(column.to_s) ? column.to_s : fallback
12
+ direction = direction.to_s
13
+ direction = natural_directions.fetch(column) unless DIRECTIONS.include?(direction)
14
+ Choice.new(column: column, direction: direction)
15
+ end
7
16
  end
8
17
  end
9
18
  end
@@ -28,18 +28,14 @@ module LlmCostTracker
28
28
  attr_reader :scope, :from, :to
29
29
 
30
30
  def alerts
31
- window_days = WINDOW_DAYS.to_f
32
- daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
31
+ daily_spend_by_model.filter_map do |(provider, model), daily_costs|
33
32
  latest_spend = daily_costs.fetch(to, 0.0)
34
33
  next unless latest_spend.positive?
35
34
 
36
- baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
37
- mean = baseline_days.sum / window_days
38
- variance = baseline_days.sum { |value| (value - mean)**2 } / window_days
39
- threshold = mean + (2 * Math.sqrt(variance))
40
- next unless latest_spend > threshold
35
+ mean, deviation = baseline(daily_costs)
36
+ next unless latest_spend > mean + (2 * deviation)
41
37
 
42
- rows << {
38
+ {
43
39
  provider: provider,
44
40
  model: model,
45
41
  day: to,
@@ -50,22 +46,28 @@ module LlmCostTracker
50
46
  end
51
47
  end
52
48
 
53
- def daily_spend_by_model
54
- window = (to - WINDOW_DAYS).beginning_of_day..to.end_of_day
49
+ def baseline(daily_costs)
50
+ days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
51
+ mean = days.sum / WINDOW_DAYS.to_f
52
+ variance = days.sum { |value| (value - mean)**2 } / WINDOW_DAYS.to_f
53
+ [mean, Math.sqrt(variance)]
54
+ end
55
55
 
56
- grouped = Hash.new { |hash, key| hash[key] = Hash.new(0.0) }
56
+ def daily_spend_by_model
57
+ daily_costs = Hash.new { |hash, key| hash[key] = Hash.new(0.0) }
58
+ daily_totals.each do |(provider, model, day), total_cost|
59
+ daily_costs[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
60
+ end
61
+ daily_costs
62
+ end
57
63
 
64
+ def daily_totals
58
65
  scope
59
- .where(tracked_at: window)
66
+ .where(tracked_at: (to - WINDOW_DAYS).beginning_of_day..to.end_of_day)
60
67
  .where.not(total_cost: nil)
61
68
  .group(:provider, :model)
62
- .group_by_period(:day)
69
+ .group_by_period(:day, time_zone: Time.zone)
63
70
  .sum(:total_cost)
64
- .each do |(provider, model, day), total_cost|
65
- grouped[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
66
- end
67
-
68
- grouped
69
71
  end
70
72
  end
71
73
  end
@@ -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
@@ -4,7 +4,6 @@ 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
7
  DEFAULT_DIRECTIONS = { "value" => "asc", "calls" => "desc", "cost" => "desc", "avg_cost" => "desc" }.freeze
9
8
  Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
10
9
 
@@ -21,8 +20,7 @@ module LlmCostTracker
21
20
  @key = LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
22
21
  limit = limit.to_i
23
22
  @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]
23
+ @order = Sort.resolve(sort, direction, natural_directions: DEFAULT_DIRECTIONS, fallback: "cost")
26
24
  end
27
25
 
28
26
  def rows
@@ -35,7 +33,7 @@ module LlmCostTracker
35
33
  calls: calls,
36
34
  total_cost: row.total_cost,
37
35
  average_cost_per_call: row.average_cost_per_call,
38
- share_percent: total.positive? ? (calls.to_f / total) * 100.0 : 0.0
36
+ share_percent: Percent.of(calls, total)
39
37
  )
40
38
  end
41
39
  end
@@ -55,7 +53,7 @@ module LlmCostTracker
55
53
 
56
54
  private
57
55
 
58
- attr_reader :scope, :key, :sort, :direction
56
+ attr_reader :scope, :key, :order
59
57
 
60
58
  def summary_counts
61
59
  @summary_counts ||= scope.klass.find_by_sql(summary_sql).first
@@ -77,8 +75,8 @@ module LlmCostTracker
77
75
  end
78
76
 
79
77
  def order_clause
80
- dir = direction.upcase
81
- case sort
78
+ dir = order.direction.upcase
79
+ case order.column
82
80
  when "value" then "#{tag_value_column} #{dir}"
83
81
  when "calls" then "COUNT(*) #{dir}, total_cost DESC"
84
82
  when "avg_cost" then "average_cost_per_call #{dir}, total_cost DESC"
@@ -89,7 +87,7 @@ module LlmCostTracker
89
87
  def summary_sql
90
88
  <<~SQL.squish
91
89
  SELECT COUNT(*) AS total_calls,
92
- COUNT(t.#{quote_column('value')}) AS tagged_calls,
90
+ COUNT(CASE WHEN #{tag_present_predicate} THEN 1 END) AS tagged_calls,
93
91
  COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_value_column} END) AS distinct_values
94
92
  FROM (#{scope.to_sql}) AS sub
95
93
  LEFT OUTER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
@@ -109,11 +107,11 @@ module LlmCostTracker
109
107
  end
110
108
 
111
109
  def quote_column(name)
112
- scope.connection.quote_column_name(name)
110
+ scope.lease_connection.quote_column_name(name)
113
111
  end
114
112
 
115
113
  def quoted_key
116
- scope.connection.quote(key)
114
+ scope.lease_connection.quote(key)
117
115
  end
118
116
  end
119
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
@@ -38,6 +35,7 @@ module LlmCostTracker
38
35
  COUNT(DISTINCT t.#{value_column}) AS distinct_values
39
36
  FROM (#{scope.to_sql}) AS sub
40
37
  INNER JOIN #{tags_table} t ON t.llm_cost_tracker_call_id = sub.id
38
+ WHERE t.#{value_column} != ''
41
39
  GROUP BY t.#{key_column}
42
40
  ORDER BY calls_count DESC
43
41
  LIMIT #{limit}
@@ -5,18 +5,16 @@ require "date"
5
5
  module LlmCostTracker
6
6
  module Dashboard
7
7
  class TimeSeries
8
- DEFAULT_DAYS = 30
9
-
10
8
  class << self
11
- def call(scope: LlmCostTracker::Call.all, from: nil, to: Date.current)
9
+ def call(from:, to:, scope: LlmCostTracker::Call.all)
12
10
  new(scope: scope, from: from, to: to).points
13
11
  end
14
12
  end
15
13
 
16
14
  def initialize(scope:, from:, to:)
17
15
  @scope = scope
16
+ @from = from.to_date
18
17
  @to = to.to_date
19
- @from = from ? from.to_date : (@to - (DEFAULT_DAYS - 1))
20
18
  end
21
19
 
22
20
  def points
@@ -35,7 +33,7 @@ module LlmCostTracker
35
33
  def scoped_costs
36
34
  scope
37
35
  .where(tracked_at: from.beginning_of_day..to.end_of_day)
38
- .group_by_period(:day)
36
+ .group_by_period(:day, time_zone: Time.zone)
39
37
  .sum(:total_cost)
40
38
  .transform_values(&:to_f)
41
39
  end
@@ -4,7 +4,6 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  class TopModels
6
6
  DEFAULT_LIMIT = 5
7
- SORT_OPTIONS = %w[cost calls avg_cost latency tokens provider name].freeze
8
7
  DEFAULT_SORT = "cost"
9
8
  DEFAULT_DIRECTIONS = {
10
9
  "provider" => "asc",
@@ -43,21 +42,20 @@ module LlmCostTracker
43
42
  def initialize(scope:, limit:, sort: DEFAULT_SORT, direction: nil)
44
43
  @scope = scope
45
44
  @limit = limit
46
- @sort = SORT_OPTIONS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
47
- @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)
48
46
  end
49
47
 
50
48
  def rows
51
49
  scope
52
50
  .group(:provider, :model)
53
51
  .select(selects)
54
- .order(*ORDER_NODES.fetch([sort, direction]))
52
+ .order(*ORDER_NODES.fetch([order.column, order.direction]))
55
53
  .then { |r| limit ? r.limit(limit) : r }
56
54
  end
57
55
 
58
56
  private
59
57
 
60
- attr_reader :scope, :limit, :sort, :direction
58
+ attr_reader :scope, :limit, :order
61
59
 
62
60
  def selects
63
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,18 +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 = [] %>
9
- <% unless @call.total_cost.nil? %>
10
- <% cost_segments = priced_components.map do |component|
11
- value = line_item_costs_by_component[component.fetch(:price_key)]
12
- { label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
13
- end %>
14
- <% end %>
15
- <% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
1
+ <% service_line_items = call_service_line_items(@call) %>
16
2
 
17
3
  <p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
18
4
  <h2 class="lct-page-title"><code class="lct-code-id"><%= @call.model %></code> <span class="lct-page-title-meta">#<%= @call.id %></span></h2>
@@ -45,7 +31,7 @@ end %>
45
31
  <section class="lct-panel">
46
32
  <div class="lct-panel-head"><h2 class="lct-panel-title">Token mix</h2></div>
47
33
  <div class="lct-panel-body">
48
- <%= 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." %>
49
35
  </div>
50
36
  </section>
51
37
 
@@ -55,7 +41,7 @@ end %>
55
41
  <% if @call.total_cost.nil? %>
56
42
  <p class="lct-stack-empty">Pricing not available for this call.</p>
57
43
  <% else %>
58
- <%= 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." %>
59
45
  <% end %>
60
46
  </div>
61
47
  </section>
@@ -84,22 +70,11 @@ end %>
84
70
  <dd><%= content_tag(:code, @call.provider_response_id, class: "lct-code-id") %></dd>
85
71
  </div>
86
72
  <% end %>
87
- <% if @call.provider_project_id.present? %>
88
- <div class="lct-meta-strip-item">
89
- <dt>Project ID</dt>
90
- <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_project_id, @call.provider_project_id), class: "lct-code-id") %></dd>
91
- </div>
92
- <% end %>
93
- <% if @call.provider_api_key_id.present? %>
94
- <div class="lct-meta-strip-item">
95
- <dt>API Key ID</dt>
96
- <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id), class: "lct-code-id") %></dd>
97
- </div>
98
- <% end %>
99
- <% if @call.provider_workspace_id.present? %>
73
+ <% { provider_project_id: "Project ID", provider_api_key_id: "API Key ID", provider_workspace_id: "Workspace ID" }.each do |attribute, label| %>
74
+ <% next if @call[attribute].blank? %>
100
75
  <div class="lct-meta-strip-item">
101
- <dt>Workspace ID</dt>
102
- <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id), class: "lct-code-id") %></dd>
76
+ <dt><%= label %></dt>
77
+ <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(attribute, @call[attribute]), class: "lct-code-id") %></dd>
103
78
  </div>
104
79
  <% end %>
105
80
  </dl>
@@ -271,7 +271,7 @@
271
271
  <h2 class="lct-panel-title">Incomplete pricing by model</h2>
272
272
  <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(current_query(cost_status: "incomplete", sort: nil)) %></span>
273
273
  </div>
274
- <p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
274
+ <p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls. A partial call is left as recorded when a rate it already used has changed since.</p>
275
275
 
276
276
  <div class="lct-table-wrap">
277
277
  <table class="lct-tbl">
@@ -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,3 @@
1
- <% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
2
-
3
1
  <div class="lct-bar-track" aria-hidden="true">
4
- <div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill<%= variant_class %>"></div>
2
+ <div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill"></div>
5
3
  </div>
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <details class="lct-filter-pop" name="lct-filter">
4
2
  <summary class="lct-filter-pill">
5
3
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
@@ -8,8 +6,7 @@
8
6
  <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
9
7
  </summary>
10
8
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
11
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
12
- <%= hidden_query_fields(current_query.except(:from, :to, :page, :per, *extra_except)) %>
9
+ <%= hidden_query_fields(current_query.except(:from, :to, :page, :per)) %>
13
10
  <div class="lct-filter-pop-field"><label for="lct-filter-from">From</label><input type="date" name="from" id="lct-filter-from" value="<%= @from_date.iso8601 %>"></div>
14
11
  <div class="lct-filter-pop-field"><label for="lct-filter-to">To</label><input type="date" name="to" id="lct-filter-to" value="<%= @to_date.iso8601 %>"></div>
15
12
  <button type="submit" class="lct-button lct-button-primary">Apply</button>