llm_cost_tracker 0.14.0 → 0.14.2

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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. metadata +23 -10
@@ -13,7 +13,6 @@ module LlmCostTracker
13
13
  :missing_latency_count,
14
14
  :streaming_count,
15
15
  :streaming_missing_usage,
16
- :missing_provider_response_id_count,
17
16
  :calls_with_pricing,
18
17
  :tagged_calls,
19
18
  :calls_with_latency,
@@ -52,7 +51,7 @@ module LlmCostTracker
52
51
  return nil unless Budget::PerTag.columns?
53
52
  return nil unless LlmCostTracker::CallTag.exists?
54
53
 
55
- unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.exists?(key: key) }
54
+ unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.where(key: key).where.not(value: "").exists? }
56
55
  return nil if unseen.empty?
57
56
 
58
57
  UnseenBudgetTags.new(keys: unseen)
@@ -79,7 +78,6 @@ module LlmCostTracker
79
78
  missing_latency_count,
80
79
  streaming_count,
81
80
  streaming_missing_usage,
82
- missing_provider_response_id_count,
83
81
  calls_with_pricing,
84
82
  tagged_calls,
85
83
  calls_with_latency,
@@ -142,7 +140,6 @@ module LlmCostTracker
142
140
  token_value = stats[component.token_key].to_i
143
141
 
144
142
  {
145
- price_key: component.key,
146
143
  token_key: component.token_key,
147
144
  cost_key: component.cost_key,
148
145
  token_value: token_value,
@@ -154,7 +151,6 @@ module LlmCostTracker
154
151
 
155
152
  rows + [
156
153
  {
157
- price_key: nil,
158
154
  token_key: :hidden_output_tokens,
159
155
  cost_key: nil,
160
156
  token_value: stats.hidden_output_tokens.to_i,
@@ -178,7 +174,7 @@ module LlmCostTracker
178
174
  Arel.sql("#{line_item_table}.direction"),
179
175
  Arel.sql("#{line_item_table}.cache_state"),
180
176
  Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
181
- index_costs_by_component(rows)
177
+ Usage::Catalog.costs_by_component(rows)
182
178
  end
183
179
 
184
180
  def streaming_health_rows(scope, total_streaming:)
@@ -221,13 +217,6 @@ module LlmCostTracker
221
217
 
222
218
  private
223
219
 
224
- def index_costs_by_component(rows)
225
- rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
226
- component = Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
227
- accumulator[component.key] = cost if component
228
- end
229
- end
230
-
231
220
  def percentage(numerator, denominator)
232
221
  return 0.0 unless denominator.positive?
233
222
 
@@ -239,7 +228,6 @@ module LlmCostTracker
239
228
  selects = [
240
229
  "COUNT(*) AS total_calls",
241
230
  "#{conditional_count_sql(unknown_pricing)} AS unknown_pricing_count",
242
- "#{tagged_calls_sql(scope)} AS tagged_calls_count",
243
231
  "COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
244
232
  "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
245
233
  "#{conditional_count_sql('stream')} AS streaming_count",
@@ -301,7 +289,8 @@ module LlmCostTracker
301
289
  tags_table = LlmCostTracker::CallTag.quoted_table_name
302
290
 
303
291
  "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
304
- "WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id) THEN 1 ELSE 0 END), 0)"
292
+ "WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id " \
293
+ "AND #{tags_table}.#{scope.connection.quote_column_name('value')} != '') THEN 1 ELSE 0 END), 0)"
305
294
  end
306
295
  end
307
296
  end
@@ -5,20 +5,23 @@ require "date"
5
5
  module LlmCostTracker
6
6
  module Dashboard
7
7
  class Filter
8
+ MAX_TAG_FILTERS = 10
9
+ MATCH_NOTHING = Arel.sql("1 = 0")
8
10
  STREAM_FILTER_OPTIONS = [
9
11
  ["Streaming only", "yes"],
10
12
  ["Non-streaming only", "no"]
11
13
  ].freeze
12
14
 
13
15
  class << self
14
- def call(scope: LlmCostTracker::Call.all, params: {})
15
- new(scope: scope, params: params).relation
16
+ def call(scope: LlmCostTracker::Call.all, params: {}, tags: {})
17
+ new(scope: scope, params: params, tags: tags).relation
16
18
  end
17
19
  end
18
20
 
19
- def initialize(scope:, params:)
21
+ def initialize(scope:, params:, tags: {})
20
22
  @scope = scope
21
23
  @params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
24
+ @extra_tags = tags
22
25
  end
23
26
 
24
27
  def relation
@@ -27,44 +30,44 @@ module LlmCostTracker
27
30
  filtered_scope = apply_exact_filter(filtered_scope, :provider)
28
31
  filtered_scope = apply_exact_filter(filtered_scope, :model)
29
32
  filtered_scope = apply_stream_filter(filtered_scope)
30
- filtered_scope = apply_usage_source_filter(filtered_scope)
33
+ filtered_scope = apply_exact_filter(filtered_scope, :usage_source)
31
34
  apply_tag_filters(filtered_scope)
32
35
  end
33
36
 
34
37
  private
35
38
 
36
- attr_reader :scope, :params
39
+ attr_reader :scope, :params, :extra_tags
37
40
 
38
41
  def apply_date_filters(relation)
39
- from_date = Dashboard::DateRange.parse(params, :from)
40
- to_date = Dashboard::DateRange.parse(params, :to)
41
- Dashboard::DateRange.validate!(from: from_date, to: to_date)
42
-
43
- default_range = Dashboard::DateRange.call(params: params)
44
- from_date ||= default_range.from
45
- to_date ||= default_range.to
42
+ Dashboard::DateRange.validate!(from: Dashboard::DateRange.parse(params, :from),
43
+ to: Dashboard::DateRange.parse(params, :to))
44
+ range = Dashboard::DateRange.call(params: params)
46
45
 
47
46
  relation
48
- .where(tracked_at: from_date.beginning_of_day..)
49
- .where(tracked_at: ..to_date.end_of_day)
47
+ .where(tracked_at: range.from.beginning_of_day..)
48
+ .where(tracked_at: ..range.to.end_of_day)
50
49
  end
51
50
 
52
51
  def apply_exact_filter(relation, key)
53
- value = normalized_string(params[key])
52
+ value = normalized_string(params[key], key)
54
53
  return relation if value.nil?
55
54
 
56
- relation.where(key => value)
55
+ matchable?([value]) ? relation.where(key => value) : relation.where(MATCH_NOTHING)
57
56
  end
58
57
 
59
58
  def apply_tag_filters(relation)
60
- tags = tag_params
59
+ tags = tag_params.merge(extra_tags)
61
60
  return relation if tags.empty?
61
+ if tags.size > MAX_TAG_FILTERS
62
+ raise LlmCostTracker::InvalidFilterError,
63
+ "at most #{MAX_TAG_FILTERS} tag filters are allowed, got #{tags.size}"
64
+ end
62
65
 
63
- relation.by_tags(tags)
66
+ matchable?(tags.values) ? relation.by_tags(tags) : relation.where(MATCH_NOTHING)
64
67
  end
65
68
 
66
69
  def apply_stream_filter(relation)
67
- value = normalized_string(params[:stream])
70
+ value = normalized_string(params[:stream], :stream)
68
71
  return relation if value.nil?
69
72
 
70
73
  case value.downcase
@@ -74,28 +77,24 @@ module LlmCostTracker
74
77
  end
75
78
  end
76
79
 
77
- def apply_usage_source_filter(relation)
78
- value = normalized_string(params[:usage_source])
79
- return relation if value.nil?
80
-
81
- relation.where(usage_source: value)
82
- end
83
-
84
80
  def tag_params
85
81
  tags = LlmCostTracker::Dashboard::Params.to_hash(params[:tag])
86
82
 
87
83
  tags.each_with_object({}) do |(key, value), normalized|
88
- value = normalized_string(value)
84
+ value = normalized_string(value, "tag filter value")
89
85
  next if value.nil?
90
86
 
91
87
  normalized[LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
92
88
  end
93
89
  end
94
90
 
95
- def normalized_string(value)
96
- return nil if value.nil?
91
+ def matchable?(values)
92
+ values.none? { |value| value.include?("\0") } ||
93
+ !LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
94
+ end
97
95
 
98
- value.to_s.strip.presence
96
+ def normalized_string(value, name)
97
+ LlmCostTracker::Dashboard::Params.scalar(value, name).strip.presence
99
98
  end
100
99
  end
101
100
  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
@@ -3,6 +3,8 @@
3
3
  module LlmCostTracker
4
4
  module Dashboard
5
5
  module Params
6
+ QUERY_KEYS = %i[from to provider model tag tag_value stream usage_source cost_status sort dir page per].freeze
7
+
6
8
  class << self
7
9
  def to_hash(value)
8
10
  return {} if value.nil?
@@ -17,6 +19,14 @@ module LlmCostTracker
17
19
  {}
18
20
  end
19
21
 
22
+ def scalar(value, name)
23
+ if value.is_a?(Hash) || value.is_a?(Array) || value.respond_to?(:to_unsafe_h)
24
+ raise InvalidFilterError, "#{name} must be a single value"
25
+ end
26
+
27
+ value.to_s
28
+ end
29
+
20
30
  def tag_query(value)
21
31
  to_hash(value).each_with_object({}) do |(key, tag_value), tags|
22
32
  key = key.to_s
@@ -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
@@ -59,7 +59,7 @@ module LlmCostTracker
59
59
  .where(tracked_at: window)
60
60
  .where.not(total_cost: nil)
61
61
  .group(:provider, :model)
62
- .group_by_period(:day)
62
+ .group_by_period(:day, time_zone: Time.zone)
63
63
  .sum(:total_cost)
64
64
  .each do |(provider, model, day), total_cost|
65
65
  grouped[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
@@ -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,7 +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"
23
+ @sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : "cost"
25
24
  @direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
26
25
  end
27
26
 
@@ -89,7 +88,7 @@ module LlmCostTracker
89
88
  def summary_sql
90
89
  <<~SQL.squish
91
90
  SELECT COUNT(*) AS total_calls,
92
- COUNT(t.#{quote_column('value')}) AS tagged_calls,
91
+ COUNT(CASE WHEN #{tag_present_predicate} THEN 1 END) AS tagged_calls,
93
92
  COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_value_column} END) AS distinct_values
94
93
  FROM (#{scope.to_sql}) AS sub
95
94
  LEFT OUTER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
@@ -38,6 +38,7 @@ module LlmCostTracker
38
38
  COUNT(DISTINCT t.#{value_column}) AS distinct_values
39
39
  FROM (#{scope.to_sql}) AS sub
40
40
  INNER JOIN #{tags_table} t ON t.llm_cost_tracker_call_id = sub.id
41
+ WHERE t.#{value_column} != ''
41
42
  GROUP BY t.#{key_column}
42
43
  ORDER BY calls_count DESC
43
44
  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,7 +42,7 @@ 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
45
+ @sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
47
46
  @direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
48
47
  end
49
48
 
@@ -5,13 +5,10 @@
5
5
  value = @call[token_key]
6
6
  { label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
7
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 %>
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 %>
15
12
  <% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
16
13
 
17
14
  <p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
@@ -84,22 +81,11 @@ end %>
84
81
  <dd><%= content_tag(:code, @call.provider_response_id, class: "lct-code-id") %></dd>
85
82
  </div>
86
83
  <% 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? %>
84
+ <% { provider_project_id: "Project ID", provider_api_key_id: "API Key ID", provider_workspace_id: "Workspace ID" }.each do |attribute, label| %>
85
+ <% next if @call[attribute].blank? %>
100
86
  <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>
87
+ <dt><%= label %></dt>
88
+ <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(attribute, @call[attribute]), class: "lct-code-id") %></dd>
103
89
  </div>
104
90
  <% end %>
105
91
  </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">
@@ -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>
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:model].presence %>
4
2
  <details class="lct-filter-pop" name="lct-filter">
5
3
  <summary class="lct-filter-pill <%= 'lct-active' if active %>">
@@ -9,8 +7,7 @@
9
7
  <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>
10
8
  </summary>
11
9
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
- <%= hidden_query_fields(current_query.except(:model, :page, :per, *extra_except)) %>
10
+ <%= hidden_query_fields(current_query.except(:model, :page, :per)) %>
14
11
  <div class="lct-filter-pop-field">
15
12
  <label for="lct-filter-model">Model</label>
16
13
  <%= select_tag :model, options_for_select(filter_options_for(:model), active), include_blank: "All models", id: "lct-filter-model" %>
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:provider].presence %>
4
2
  <details class="lct-filter-pop" name="lct-filter">
5
3
  <summary class="lct-filter-pill <%= 'lct-active' if active %>">
@@ -9,8 +7,7 @@
9
7
  <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>
10
8
  </summary>
11
9
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
- <%= hidden_query_fields(current_query.except(:provider, :page, :per, *extra_except)) %>
10
+ <%= hidden_query_fields(current_query.except(:provider, :page, :per)) %>
14
11
  <div class="lct-filter-pop-field">
15
12
  <label for="lct-filter-provider">Provider</label>
16
13
  <%= select_tag :provider, options_for_select(filter_options_for(:provider), active), include_blank: "All providers", id: "lct-filter-provider" %>
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:stream].presence %>
4
2
  <% display_value = case active when "yes" then "Streaming" when "no" then "Non-streaming" else "All" end %>
5
3
  <details class="lct-filter-pop" name="lct-filter">
@@ -10,8 +8,7 @@
10
8
  <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>
11
9
  </summary>
12
10
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
13
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
14
- <%= hidden_query_fields(current_query.except(:stream, :page, :per, *extra_except)) %>
11
+ <%= hidden_query_fields(current_query.except(:stream, :page, :per)) %>
15
12
  <div class="lct-filter-pop-field">
16
13
  <label for="lct-filter-stream">Stream</label>
17
14
  <%= select_tag :stream, options_for_select(LlmCostTracker::Dashboard::Filter::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
@@ -5,9 +5,9 @@
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
 
7
7
  <div class="lct-filter-row">
8
- <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
9
- <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
10
- <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
8
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url %>
9
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url %>
10
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url %>
11
11
 
12
12
  <% if params[:provider].present? || params[:model].present? %>
13
13
  <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
@@ -35,7 +35,7 @@
35
35
  </div>
36
36
  <div class="lct-stat">
37
37
  <div class="lct-stat-head"><p class="lct-stat-label">Avg cost / call</p></div>
38
- <p class="lct-stat-value"><%= money(@value_calls.positive? ? @value_total_cost / @value_calls : 0) %></p>
38
+ <p class="lct-stat-value"><%= money(@value_total_cost / @value_calls) %></p>
39
39
  </div>
40
40
  </div>
41
41
 
@@ -44,7 +44,7 @@
44
44
  <h2 class="lct-panel-title">Spend over time</h2>
45
45
  <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(calls_query_for_tag(key: @key, value: @value)) %></span>
46
46
  </div>
47
- <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points, comparison_series: [] %>
47
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
48
48
  </section>
49
49
  <% end %>
50
50
 
@@ -107,6 +107,7 @@
107
107
  </tr>
108
108
  </thead>
109
109
  <tbody>
110
+ <% drilldown_allowed = tag_drilldown_allowed?(params[:key]) %>
110
111
  <% @breakdown.rows.each do |row| %>
111
112
  <tr>
112
113
  <td><code class="lct-code-id"><%= row.value %></code></td>
@@ -117,6 +118,8 @@
117
118
  <td class="lct-num">
118
119
  <% if row.value == "(untagged)" %>
119
120
  <span class="lct-num-muted">n/a</span>
121
+ <% elsif !drilldown_allowed %>
122
+ <span class="lct-num-muted">tag filter limit</span>
120
123
  <% else %>
121
124
  <%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-page-link" %>
122
125
  <%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-page-link" %>
data/config/routes.rb CHANGED
@@ -4,7 +4,12 @@ LlmCostTracker::Engine.routes.draw do
4
4
  root "dashboard#index"
5
5
  resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
6
6
  resources :models, only: :index
7
- resources :tags, only: %i[index show], param: :key, format: false
7
+ resources :tags,
8
+ only: %i[index show],
9
+ param: :key,
10
+ format: false,
11
+ constraints: { key: %r{[^/]+} },
12
+ defaults: { format: :html }
8
13
  get "data_quality", to: "data_quality#index", as: :data_quality
9
14
  get "pricing", to: "pricing#index", as: :pricing
10
15
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../ledger/isolation"
3
4
  require_relative "../ledger/schema/adapter"
4
5
  require_relative "../ledger/tags/encoding"
5
6
 
@@ -29,7 +30,7 @@ module LlmCostTracker
29
30
  end
30
31
 
31
32
  def blocking?
32
- active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
33
+ configured.each_value.any? { |entry| behavior_for(entry) == :block_requests } && active?
33
34
  end
34
35
 
35
36
  def rules_for(tags, blocking_only: false)
@@ -37,13 +38,13 @@ module LlmCostTracker
37
38
 
38
39
  normalized = (tags || {}).to_h.transform_keys(&:to_s)
39
40
  configured.filter_map do |key, entry|
40
- raw = normalized[key]
41
- next if raw.nil?
41
+ value = Ledger::Tags::Encoding.encode(normalized[key])
42
+ next if value.empty?
42
43
  next if blocking_only && behavior_for(entry) != :block_requests
43
44
 
44
45
  Rule.new(
45
46
  key: key,
46
- value: Ledger::Tags::Encoding.encode(raw),
47
+ value: value,
47
48
  windows: entry[:windows],
48
49
  behavior: behavior_for(entry),
49
50
  on_exceeded: on_exceeded_for(entry)
@@ -52,20 +53,28 @@ module LlmCostTracker
52
53
  end
53
54
 
54
55
  def rules_for_events(events)
55
- events.each_with_object({}) do |event, grouped|
56
+ events.select(&:total_cost).each_with_object({}) do |event, grouped|
56
57
  rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
57
58
  end
58
59
  end
59
60
 
60
61
  def spend(key, value, window, time:)
61
- spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
62
+ spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first.to_d
62
63
  end
63
64
 
64
- def spend_by_value(key, values, window, bucket)
65
+ def spend_by_value(key, values, window, bucket, upto = nil)
65
66
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
- totals = LlmCostTracker::CallTag
67
- .where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
68
- .group(:value).sum(COST_COLUMN)
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
+ )
71
+ 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)] }
77
+ end
69
78
  warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
70
79
  totals
71
80
  end
@@ -88,7 +97,8 @@ module LlmCostTracker
88
97
  end
89
98
 
90
99
  def columns?
91
- (LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
100
+ LlmCostTracker::CallTag.table_exists? &&
101
+ (LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
92
102
  end
93
103
 
94
104
  private