llm_cost_tracker 0.2.0.alpha2 → 0.2.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +4 -3
  4. data/app/assets/llm_cost_tracker/application.css +760 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +13 -0
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  10. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +42 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  14. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  15. data/app/services/llm_cost_tracker/dashboard/filter.rb +0 -3
  16. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  20. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +106 -74
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +201 -111
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +178 -78
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  30. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  31. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  32. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  33. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  34. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  35. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  37. data/config/routes.rb +3 -0
  38. data/lib/llm_cost_tracker/assets.rb +24 -0
  39. data/lib/llm_cost_tracker/engine.rb +2 -0
  40. data/lib/llm_cost_tracker/llm_api_call.rb +1 -1
  41. data/lib/llm_cost_tracker/price_registry.rb +17 -6
  42. data/lib/llm_cost_tracker/pricing.rb +19 -6
  43. data/lib/llm_cost_tracker/retention.rb +34 -0
  44. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  45. data/lib/llm_cost_tracker/tags_column.rb +13 -1
  46. data/lib/llm_cost_tracker/version.rb +1 -1
  47. data/lib/llm_cost_tracker.rb +1 -0
  48. data/lib/tasks/llm_cost_tracker.rake +8 -0
  49. data/llm_cost_tracker.gemspec +1 -2
  50. metadata +17 -5
  51. data/PLAN_0.2.md +0 -488
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module LlmCostTracker
4
6
  # Calculates costs from price entries expressed in USD per 1M tokens.
5
7
  module Pricing
6
8
  PRICES = PriceRegistry.builtin_prices
9
+ MUTEX = Monitor.new
7
10
 
8
11
  class << self
9
12
  # Estimate model cost from token counts.
@@ -59,9 +62,14 @@ module LlmCostTracker
59
62
  cached = @prices_cache
60
63
  return cached[:value] if cached && cached[:key] == cache_key
61
64
 
62
- value = PRICES.merge(file_prices).merge(overrides).freeze
63
- @prices_cache = { key: cache_key, value: value }.freeze
64
- value
65
+ MUTEX.synchronize do
66
+ cached = @prices_cache
67
+ return cached[:value] if cached && cached[:key] == cache_key
68
+
69
+ value = PRICES.merge(file_prices).merge(overrides).freeze
70
+ @prices_cache = { key: cache_key, value: value }.freeze
71
+ value
72
+ end
65
73
  end
66
74
 
67
75
  private
@@ -116,9 +124,14 @@ module LlmCostTracker
116
124
  cached = @sorted_price_keys_cache
117
125
  return cached[:keys] if cached && cached[:table].equal?(table)
118
126
 
119
- keys = table.keys.sort_by { |key| -key.length }
120
- @sorted_price_keys_cache = { table: table, keys: keys }.freeze
121
- keys
127
+ MUTEX.synchronize do
128
+ cached = @sorted_price_keys_cache
129
+ return cached[:keys] if cached && cached[:table].equal?(table)
130
+
131
+ keys = table.keys.sort_by { |key| -key.length }
132
+ @sorted_price_keys_cache = { table: table, keys: keys }.freeze
133
+ keys
134
+ end
122
135
  end
123
136
  end
124
137
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Retention
5
+ DEFAULT_BATCH_SIZE = 5_000
6
+
7
+ class << self
8
+ def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
9
+ cutoff = resolve_cutoff(older_than, now)
10
+ require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
11
+
12
+ deleted = 0
13
+ loop do
14
+ batch = LlmCostTracker::LlmApiCall.where(tracked_at: ...cutoff).limit(batch_size).delete_all
15
+ deleted += batch
16
+ break if batch < batch_size
17
+ end
18
+ deleted
19
+ end
20
+
21
+ private
22
+
23
+ def resolve_cutoff(older_than, now)
24
+ case older_than
25
+ when Time, DateTime then older_than.utc
26
+ when ActiveSupport::Duration then now - older_than
27
+ when Integer then now - (older_than * 86_400)
28
+ else
29
+ raise ArgumentError, "older_than must be a Duration, Time, or Integer days: #{older_than.inspect}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -9,7 +9,8 @@ module LlmCostTracker
9
9
  normalized_tags = normalize_tags(tags)
10
10
  return model.all if normalized_tags.empty?
11
11
 
12
- return json_query(model, normalized_tags) if model.tags_json_column?
12
+ return postgres_json_query(model, normalized_tags) if model.tags_jsonb_column?
13
+ return mysql_json_query(model, normalized_tags) if model.tags_mysql_json_column?
13
14
 
14
15
  text_query(model, normalized_tags)
15
16
  end
@@ -20,10 +21,14 @@ module LlmCostTracker
20
21
 
21
22
  private
22
23
 
23
- def json_query(model, tags)
24
+ def postgres_json_query(model, tags)
24
25
  model.where("tags @> ?::jsonb", tags.to_json)
25
26
  end
26
27
 
28
+ def mysql_json_query(model, tags)
29
+ model.where("JSON_CONTAINS(tags, ?)", tags.to_json)
30
+ end
31
+
27
32
  def text_query(model, tags)
28
33
  tags.reduce(model.all) do |relation, (key, value)|
29
34
  relation.where("tags LIKE ? ESCAPE '\\'", "%#{model.sanitize_sql_like(json_tag_fragment(key, value))}%")
@@ -3,10 +3,22 @@
3
3
  module LlmCostTracker
4
4
  module TagsColumn
5
5
  def tags_json_column?
6
+ tags_jsonb_column? || tags_mysql_json_column?
7
+ end
8
+
9
+ def tags_jsonb_column?
10
+ column = columns_hash["tags"]
11
+ return false unless column
12
+
13
+ column.type == :jsonb || column.sql_type.to_s.downcase == "jsonb"
14
+ end
15
+
16
+ def tags_mysql_json_column?
6
17
  column = columns_hash["tags"]
7
18
  return false unless column
19
+ return false if tags_jsonb_column?
8
20
 
9
- %i[json jsonb].include?(column.type) || column.sql_type.to_s.downcase == "jsonb"
21
+ column.type == :json && connection.adapter_name.match?(/mysql/i)
10
22
  end
11
23
 
12
24
  def latency_column?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.2.0.alpha2"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -28,6 +28,7 @@ require_relative "llm_cost_tracker/tag_key"
28
28
  require_relative "llm_cost_tracker/tag_query"
29
29
  require_relative "llm_cost_tracker/tag_accessors"
30
30
  require_relative "llm_cost_tracker/tracker"
31
+ require_relative "llm_cost_tracker/retention"
31
32
  require_relative "llm_cost_tracker/report_data"
32
33
  require_relative "llm_cost_tracker/report_formatter"
33
34
  require_relative "llm_cost_tracker/report"
@@ -6,4 +6,12 @@ namespace :llm_cost_tracker do
6
6
  days = (ENV["DAYS"] || LlmCostTracker::Report::DEFAULT_DAYS).to_i
7
7
  puts LlmCostTracker::Report.generate(days: days)
8
8
  end
9
+
10
+ desc "Delete llm_api_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
11
+ task prune: :environment do
12
+ days = (ENV["DAYS"] || 90).to_i
13
+ batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
14
+ deleted = LlmCostTracker::Retention.prune(older_than: days, batch_size: batch_size)
15
+ puts "llm_cost_tracker: pruned #{deleted} calls older than #{days} days"
16
+ end
9
17
  end
@@ -19,7 +19,6 @@ Gem::Specification.new do |spec|
19
19
  spec.required_ruby_version = ">= 3.3.0"
20
20
 
21
21
  spec.metadata["homepage_uri"] = spec.homepage
22
- spec.metadata["source_code_uri"] = spec.homepage
23
22
  spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
24
23
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
25
24
  spec.metadata["rubygems_mfa_required"] = "true"
@@ -34,7 +33,7 @@ Gem::Specification.new do |spec|
34
33
  spec.require_paths = ["lib"]
35
34
 
36
35
  spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
37
- spec.add_dependency "csv", ">= 3.0"
36
+ spec.add_dependency "csv", "~> 3.0"
38
37
  spec.add_dependency "faraday", ">= 2.0", "< 3.0"
39
38
 
40
39
  spec.add_development_dependency "activerecord", ">= 7.1", "< 9.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.alpha2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
@@ -34,14 +34,14 @@ dependencies:
34
34
  name: csv
35
35
  requirement: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '3.0'
40
40
  type: :runtime
41
41
  prerelease: false
42
42
  version_requirements: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - ">="
44
+ - - "~>"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '3.0'
47
47
  - !ruby/object:Gem::Dependency
@@ -193,20 +193,27 @@ files:
193
193
  - ".rspec"
194
194
  - CHANGELOG.md
195
195
  - LICENSE.txt
196
- - PLAN_0.2.md
197
196
  - README.md
198
197
  - Rakefile
198
+ - app/assets/llm_cost_tracker/application.css
199
199
  - app/controllers/llm_cost_tracker/application_controller.rb
200
+ - app/controllers/llm_cost_tracker/assets_controller.rb
200
201
  - app/controllers/llm_cost_tracker/calls_controller.rb
201
202
  - app/controllers/llm_cost_tracker/dashboard_controller.rb
202
203
  - app/controllers/llm_cost_tracker/data_quality_controller.rb
203
204
  - app/controllers/llm_cost_tracker/models_controller.rb
204
205
  - app/controllers/llm_cost_tracker/tags_controller.rb
205
206
  - app/helpers/llm_cost_tracker/application_helper.rb
207
+ - app/helpers/llm_cost_tracker/chart_helper.rb
208
+ - app/helpers/llm_cost_tracker/dashboard_filter_helper.rb
209
+ - app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb
210
+ - app/helpers/llm_cost_tracker/dashboard_query_helper.rb
211
+ - app/helpers/llm_cost_tracker/pagination_helper.rb
206
212
  - app/services/llm_cost_tracker/dashboard/data_quality.rb
207
213
  - app/services/llm_cost_tracker/dashboard/filter.rb
208
214
  - app/services/llm_cost_tracker/dashboard/overview_stats.rb
209
215
  - app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
216
+ - app/services/llm_cost_tracker/dashboard/spend_anomaly.rb
210
217
  - app/services/llm_cost_tracker/dashboard/tag_breakdown.rb
211
218
  - app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb
212
219
  - app/services/llm_cost_tracker/dashboard/time_series.rb
@@ -221,12 +228,17 @@ files:
221
228
  - app/views/llm_cost_tracker/errors/invalid_filter.html.erb
222
229
  - app/views/llm_cost_tracker/errors/not_found.html.erb
223
230
  - app/views/llm_cost_tracker/models/index.html.erb
231
+ - app/views/llm_cost_tracker/shared/_active_filters.html.erb
224
232
  - app/views/llm_cost_tracker/shared/_bar.html.erb
233
+ - app/views/llm_cost_tracker/shared/_metric_stack.html.erb
234
+ - app/views/llm_cost_tracker/shared/_spend_chart.html.erb
235
+ - app/views/llm_cost_tracker/shared/_tag_chips.html.erb
225
236
  - app/views/llm_cost_tracker/shared/setup_required.html.erb
226
237
  - app/views/llm_cost_tracker/tags/index.html.erb
227
238
  - app/views/llm_cost_tracker/tags/show.html.erb
228
239
  - config/routes.rb
229
240
  - lib/llm_cost_tracker.rb
241
+ - lib/llm_cost_tracker/assets.rb
230
242
  - lib/llm_cost_tracker/budget.rb
231
243
  - lib/llm_cost_tracker/configuration.rb
232
244
  - lib/llm_cost_tracker/cost.rb
@@ -265,6 +277,7 @@ files:
265
277
  - lib/llm_cost_tracker/report.rb
266
278
  - lib/llm_cost_tracker/report_data.rb
267
279
  - lib/llm_cost_tracker/report_formatter.rb
280
+ - lib/llm_cost_tracker/retention.rb
268
281
  - lib/llm_cost_tracker/storage/active_record_store.rb
269
282
  - lib/llm_cost_tracker/tag_accessors.rb
270
283
  - lib/llm_cost_tracker/tag_key.rb
@@ -280,7 +293,6 @@ licenses:
280
293
  - MIT
281
294
  metadata:
282
295
  homepage_uri: https://github.com/sergey-homenko/llm_cost_tracker
283
- source_code_uri: https://github.com/sergey-homenko/llm_cost_tracker
284
296
  bug_tracker_uri: https://github.com/sergey-homenko/llm_cost_tracker/issues
285
297
  changelog_uri: https://github.com/sergey-homenko/llm_cost_tracker/blob/main/CHANGELOG.md
286
298
  rubygems_mfa_required: 'true'