llm_cost_tracker 0.1.4 → 0.2.0.alpha1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -91
  3. data/PLAN_0.2.md +488 -0
  4. data/README.md +140 -320
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
  14. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
  15. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
  16. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
  17. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
  18. data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
  20. data/app/services/llm_cost_tracker/pagination.rb +59 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
  29. data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
  31. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
  32. data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
  34. data/config/routes.rb +10 -0
  35. data/lib/llm_cost_tracker/budget.rb +16 -38
  36. data/lib/llm_cost_tracker/configuration.rb +3 -1
  37. data/lib/llm_cost_tracker/cost.rb +1 -3
  38. data/lib/llm_cost_tracker/engine.rb +13 -0
  39. data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
  40. data/lib/llm_cost_tracker/errors.rb +2 -0
  41. data/lib/llm_cost_tracker/event.rb +1 -3
  42. data/lib/llm_cost_tracker/event_metadata.rb +9 -18
  43. data/lib/llm_cost_tracker/llm_api_call.rb +4 -17
  44. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
  45. data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
  46. data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
  47. data/lib/llm_cost_tracker/parsers/base.rb +3 -8
  48. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  50. data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
  51. data/lib/llm_cost_tracker/period_grouping.rb +68 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +22 -30
  53. data/lib/llm_cost_tracker/pricing.rb +10 -19
  54. data/lib/llm_cost_tracker/report.rb +4 -4
  55. data/lib/llm_cost_tracker/report_data.rb +21 -24
  56. data/lib/llm_cost_tracker/report_formatter.rb +4 -2
  57. data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
  58. data/lib/llm_cost_tracker/tag_key.rb +16 -0
  59. data/lib/llm_cost_tracker/tracker.rb +35 -1
  60. data/lib/llm_cost_tracker/version.rb +1 -1
  61. data/lib/llm_cost_tracker.rb +3 -6
  62. data/llm_cost_tracker.gemspec +13 -9
  63. metadata +91 -20
  64. data/.rubocop.yml +0 -44
  65. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
  66. data/lib/llm_cost_tracker/storage/backends.rb +0 -26
  67. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
  68. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
  69. data/lib/llm_cost_tracker/value_object.rb +0 -45
@@ -11,54 +11,50 @@ module LlmCostTracker
11
11
  EMPTY_PRICES = {}.freeze
12
12
  PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
13
13
  METADATA_KEYS = %w[_source _updated _notes].freeze
14
- FILE_PRICES_MUTEX = Mutex.new
15
- NORMALIZE_PRICE_ENTRY = lambda do |price|
16
- (price || {}).each_with_object({}) do |(key, value), normalized|
17
- key = key.to_s
18
- normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
19
- end
20
- end
21
- NORMALIZE_PRICE_TABLE = lambda do |table|
22
- (table || {}).each_with_object({}) do |(model, price), normalized|
23
- normalized[model.to_s] = NORMALIZE_PRICE_ENTRY.call(price)
24
- end
25
- end
26
- RAW_REGISTRY = JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
27
- PRICE_METADATA = RAW_REGISTRY.fetch("metadata", {}).freeze
28
- BUILTIN_PRICES = NORMALIZE_PRICE_TABLE.call(RAW_REGISTRY.fetch("models", {})).freeze
29
-
30
- private_constant :FILE_PRICES_MUTEX
31
14
 
32
15
  class << self
33
16
  def builtin_prices
34
- BUILTIN_PRICES
17
+ @builtin_prices ||= normalize_price_table(raw_registry.fetch("models", {})).freeze
35
18
  end
36
19
 
37
20
  def metadata
38
- PRICE_METADATA
21
+ @metadata ||= raw_registry.fetch("metadata", {}).freeze
39
22
  end
40
23
 
41
24
  def normalize_price_table(table)
42
- NORMALIZE_PRICE_TABLE.call(table)
25
+ (table || {}).each_with_object({}) do |(model, price), normalized|
26
+ normalized[model.to_s] = normalize_price_entry(price)
27
+ end
43
28
  end
44
29
 
45
30
  def file_prices(path)
46
31
  return EMPTY_PRICES unless path
47
32
 
48
33
  path = path.to_s
49
- FILE_PRICES_MUTEX.synchronize do
50
- cache_key = [path, File.mtime(path).to_f]
51
- return @file_prices if @file_prices_cache_key == cache_key
34
+ cache_key = [path, File.mtime(path).to_f]
35
+ cached = @file_prices_cache
36
+ return cached[:value] if cached && cached[:key] == cache_key
52
37
 
53
- @file_prices_cache_key = cache_key
54
- @file_prices = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
55
- end
38
+ value = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
39
+ @file_prices_cache = { key: cache_key, value: value }.freeze
40
+ value
56
41
  rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
57
42
  raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
58
43
  end
59
44
 
60
45
  private
61
46
 
47
+ def raw_registry
48
+ @raw_registry ||= JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
49
+ end
50
+
51
+ def normalize_price_entry(price)
52
+ (price || {}).each_with_object({}) do |(key, value), normalized|
53
+ key = key.to_s
54
+ normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
55
+ end
56
+ end
57
+
62
58
  def normalize_file_prices(table, path:)
63
59
  (table || {}).each_with_object({}) do |(model, price), normalized|
64
60
  warn_unknown_keys(model, price, path)
@@ -66,10 +62,6 @@ module LlmCostTracker
66
62
  end
67
63
  end
68
64
 
69
- def normalize_price_entry(price)
70
- NORMALIZE_PRICE_ENTRY.call(price)
71
- end
72
-
73
65
  def warn_unknown_keys(model, price, path)
74
66
  unknown_keys = price.keys.map(&:to_s) - PRICE_KEYS - METADATA_KEYS
75
67
  return if unknown_keys.empty?
@@ -4,11 +4,6 @@ module LlmCostTracker
4
4
  # Calculates costs from price entries expressed in USD per 1M tokens.
5
5
  module Pricing
6
6
  PRICES = PriceRegistry.builtin_prices
7
- PRICES_MUTEX = Mutex.new
8
- SORTED_PRICE_KEYS_MUTEX = Mutex.new
9
-
10
- private_constant :PRICES_MUTEX
11
- private_constant :SORTED_PRICE_KEYS_MUTEX
12
7
 
13
8
  class << self
14
9
  # Estimate model cost from token counts.
@@ -61,14 +56,12 @@ module LlmCostTracker
61
56
  overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
62
57
  cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
63
58
 
64
- return @prices if @prices_cache_key == cache_key
65
-
66
- PRICES_MUTEX.synchronize do
67
- return @prices if @prices_cache_key == cache_key
59
+ cached = @prices_cache
60
+ return cached[:value] if cached && cached[:key] == cache_key
68
61
 
69
- @prices_cache_key = cache_key
70
- @prices = PRICES.merge(file_prices).merge(overrides).freeze
71
- end
62
+ value = PRICES.merge(file_prices).merge(overrides).freeze
63
+ @prices_cache = { key: cache_key, value: value }.freeze
64
+ value
72
65
  end
73
66
 
74
67
  private
@@ -120,14 +113,12 @@ module LlmCostTracker
120
113
  end
121
114
 
122
115
  def sorted_price_keys(table)
123
- return @sorted_price_keys if @sorted_price_keys_table.equal?(table)
116
+ cached = @sorted_price_keys_cache
117
+ return cached[:keys] if cached && cached[:table].equal?(table)
124
118
 
125
- SORTED_PRICE_KEYS_MUTEX.synchronize do
126
- return @sorted_price_keys if @sorted_price_keys_table.equal?(table)
127
-
128
- @sorted_price_keys_table = table
129
- @sorted_price_keys = table.keys.sort_by { |key| -key.length }
130
- end
119
+ keys = table.keys.sort_by { |key| -key.length }
120
+ @sorted_price_keys_cache = { table: table, keys: keys }.freeze
121
+ keys
131
122
  end
132
123
  end
133
124
  end
@@ -13,16 +13,16 @@ module LlmCostTracker
13
13
  # @param days [Integer] Number of trailing days to include.
14
14
  # @param now [Time] Report end time.
15
15
  # @return [String]
16
- def generate(days: DEFAULT_DAYS, now: Time.now.utc)
17
- ReportFormatter.new(data(days: days, now: now)).to_s
16
+ def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
17
+ ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
18
18
  rescue LoadError => e
19
19
  "Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
20
20
  rescue StandardError => e
21
21
  "Unable to build LLM cost report: #{e.class}: #{e.message}"
22
22
  end
23
23
 
24
- def data(days: DEFAULT_DAYS, now: Time.now.utc)
25
- ReportData.build(days: days, now: now)
24
+ def data(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
25
+ ReportData.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
26
26
  end
27
27
  end
28
28
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "value_object"
3
+ require "active_support/core_ext/integer/time"
4
4
 
5
5
  module LlmCostTracker
6
- TopCall = ValueObject.define(:provider, :model, :total_cost)
6
+ TopCall = Data.define(:provider, :model, :total_cost)
7
7
 
8
- ReportData = ValueObject.define(
8
+ ReportData = Data.define(
9
9
  :days,
10
10
  :from_time,
11
11
  :to_time,
@@ -19,20 +19,21 @@ module LlmCostTracker
19
19
  :top_calls
20
20
  )
21
21
 
22
- ReportData.const_set(:DEFAULT_DAYS, 30)
23
- ReportData.const_set(:TOP_LIMIT, 5)
24
- ReportData.const_set(:DEFAULT_TAG_BREAKDOWNS, %w[feature].freeze)
22
+ class ReportData
23
+ DEFAULT_DAYS = 30
24
+ TOP_LIMIT = 5
25
25
 
26
- class << ReportData
27
- def build(days: ReportData::DEFAULT_DAYS, now: Time.now.utc)
26
+ def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
28
27
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
29
28
 
30
29
  days = normalized_days(days)
31
- scope = LlmApiCall.where(tracked_at: from_time(days, now)..now)
30
+ from = now - days.days
31
+ scope = LlmApiCall.where(tracked_at: from..now)
32
+ tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
32
33
 
33
34
  new(
34
35
  days: days,
35
- from_time: from_time(days, now),
36
+ from_time: from,
36
37
  to_time: now,
37
38
  total_cost: scope.sum(:total_cost).to_f,
38
39
  requests_count: scope.count,
@@ -40,42 +41,38 @@ module LlmCostTracker
40
41
  unknown_pricing_count: scope.where(total_cost: nil).count,
41
42
  cost_by_provider: cost_by(scope, :provider),
42
43
  cost_by_model: cost_by(scope, :model),
43
- cost_by_tags: cost_by_tags(scope, ReportData::DEFAULT_TAG_BREAKDOWNS),
44
+ cost_by_tags: cost_by_tags(scope, tag_breakdowns),
44
45
  top_calls: top_calls(scope)
45
46
  )
46
47
  end
47
48
 
48
- private
49
-
50
- def normalized_days(days)
49
+ def self.normalized_days(days)
51
50
  days = days.to_i
52
- days.positive? ? days : ReportData::DEFAULT_DAYS
53
- end
54
-
55
- def from_time(days, now)
56
- now - (days * 86_400)
51
+ days.positive? ? days : DEFAULT_DAYS
57
52
  end
58
53
 
59
- def average_latency_ms(scope)
54
+ def self.average_latency_ms(scope)
60
55
  return nil unless LlmApiCall.latency_column?
61
56
 
62
57
  scope.average(:latency_ms)&.to_f
63
58
  end
64
59
 
65
- def cost_by(scope, column)
60
+ def self.cost_by(scope, column)
66
61
  scope.group(column).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
67
62
  end
68
63
 
69
- def cost_by_tags(scope, keys)
64
+ def self.cost_by_tags(scope, keys)
70
65
  keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
71
66
  end
72
67
 
73
- def top_calls(scope)
68
+ def self.top_calls(scope)
74
69
  scope
75
70
  .where.not(total_cost: nil)
76
71
  .order(total_cost: :desc)
77
- .limit(ReportData::TOP_LIMIT)
72
+ .limit(TOP_LIMIT)
78
73
  .map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
79
74
  end
75
+
76
+ private_class_method :normalized_days, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
80
77
  end
81
78
  end
@@ -3,6 +3,8 @@
3
3
  module LlmCostTracker
4
4
  class ReportFormatter
5
5
  TOP_LIMIT = 5
6
+ NAME_COLUMN_WIDTH = 28
7
+ TOP_CALL_COLUMN_WIDTH = 32
6
8
 
7
9
  def initialize(data)
8
10
  @data = data
@@ -33,7 +35,7 @@ module LlmCostTracker
33
35
  return lines << " none" if rows.empty?
34
36
 
35
37
  rows.first(TOP_LIMIT).each do |name, cost|
36
- lines << " #{name.to_s.ljust(28)} #{money(cost)}"
38
+ lines << " #{name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(cost)}"
37
39
  end
38
40
  end
39
41
 
@@ -50,7 +52,7 @@ module LlmCostTracker
50
52
 
51
53
  @data.top_calls.first(TOP_LIMIT).each do |call|
52
54
  label = "#{call.provider}/#{call.model}"
53
- lines << " #{label.ljust(32)} #{money(call.total_cost)}"
55
+ lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
54
56
  end
55
57
  end
56
58
 
@@ -25,10 +25,8 @@ module LlmCostTracker
25
25
  end
26
26
 
27
27
  def monthly_total(time: Time.now.utc)
28
- beginning_of_month = Time.new(time.year, time.month, 1, 0, 0, 0, "+00:00")
29
-
30
28
  model_class
31
- .where(tracked_at: beginning_of_month..time)
29
+ .where(tracked_at: time.beginning_of_month..time)
32
30
  .sum(:total_cost)
33
31
  .to_f
34
32
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module TagKey
5
+ PATTERN = /\A[\w.-]+\z/
6
+
7
+ class << self
8
+ def validate!(key, error_class: ArgumentError)
9
+ key = key.to_s
10
+ return key if key.match?(PATTERN)
11
+
12
+ raise error_class, "invalid tag key: #{key.inspect}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -60,7 +60,11 @@ module LlmCostTracker
60
60
 
61
61
  def store(event)
62
62
  config = LlmCostTracker.configuration
63
- Storage::Backends.fetch(config.storage_backend).save(event, config: config)
63
+ case config.storage_backend
64
+ when :log then log_event(event, config)
65
+ when :active_record then active_record_save(event)
66
+ when :custom then custom_save(event, config)
67
+ end
64
68
  rescue BudgetExceededError, UnknownPricingError
65
69
  raise
66
70
  rescue StandardError => e
@@ -68,6 +72,36 @@ module LlmCostTracker
68
72
  false
69
73
  end
70
74
 
75
+ def log_event(event, config)
76
+ message = "#{event.provider}/#{event.model} " \
77
+ "tokens=#{event.input_tokens}+#{event.output_tokens} " \
78
+ "cost=#{log_cost_label(event)}"
79
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
80
+ message += " tags=#{event.tags}" unless event.tags.empty?
81
+
82
+ Logging.log(config.log_level, message)
83
+ event
84
+ end
85
+
86
+ def log_cost_label(event)
87
+ event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
88
+ end
89
+
90
+ def active_record_save(event)
91
+ require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
92
+ require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
93
+
94
+ Storage::ActiveRecordStore.save(event)
95
+ event
96
+ rescue LoadError => e
97
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
98
+ end
99
+
100
+ def custom_save(event, config)
101
+ result = config.custom_storage&.call(event)
102
+ result == false ? false : event
103
+ end
104
+
71
105
  def handle_storage_error(error)
72
106
  case LlmCostTracker.configuration.storage_error_behavior
73
107
  when :ignore
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0.alpha1"
5
5
  end
@@ -7,7 +7,6 @@ require_relative "llm_cost_tracker/version"
7
7
  require_relative "llm_cost_tracker/configuration"
8
8
  require_relative "llm_cost_tracker/errors"
9
9
  require_relative "llm_cost_tracker/logging"
10
- require_relative "llm_cost_tracker/value_object"
11
10
  require_relative "llm_cost_tracker/cost"
12
11
  require_relative "llm_cost_tracker/event"
13
12
  require_relative "llm_cost_tracker/parsed_usage"
@@ -25,9 +24,9 @@ require_relative "llm_cost_tracker/budget"
25
24
  require_relative "llm_cost_tracker/unknown_pricing"
26
25
  require_relative "llm_cost_tracker/event_metadata"
27
26
  require_relative "llm_cost_tracker/tags_column"
27
+ 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
- require_relative "llm_cost_tracker/storage/backends"
31
30
  require_relative "llm_cost_tracker/tracker"
32
31
  require_relative "llm_cost_tracker/report_data"
33
32
  require_relative "llm_cost_tracker/report_formatter"
@@ -35,12 +34,10 @@ require_relative "llm_cost_tracker/report"
35
34
 
36
35
  module LlmCostTracker
37
36
  class << self
38
- CONFIGURATION_MUTEX = Mutex.new
39
-
40
37
  attr_writer :configuration
41
38
 
42
39
  def configuration
43
- @configuration || CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
40
+ @configuration ||= Configuration.new
44
41
  end
45
42
 
46
43
  # Configure the gem once during application boot.
@@ -54,7 +51,7 @@ module LlmCostTracker
54
51
  end
55
52
 
56
53
  def reset_configuration!
57
- CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
54
+ @configuration = Configuration.new
58
55
  end
59
56
 
60
57
  # Track an LLM request manually for non-Faraday clients.
@@ -12,32 +12,36 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Tracks token usage and estimated costs for OpenAI, Anthropic, Google Gemini, " \
13
13
  "OpenRouter, DeepSeek, and OpenAI-compatible calls. " \
14
14
  "Works as Faraday middleware for Ruby clients, with ActiveRecord storage, " \
15
- "per-user/per-feature attribution, and budget guardrails."
15
+ "arbitrary tag-based attribution, and budget guardrails."
16
16
  spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
17
17
  spec.license = "MIT"
18
18
 
19
- spec.required_ruby_version = ">= 3.1.0"
19
+ spec.required_ruby_version = ">= 3.3.0"
20
20
 
21
- spec.metadata["homepage_uri"] = spec.homepage
22
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = spec.homepage
23
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
24
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
25
  spec.metadata["rubygems_mfa_required"] = "true"
24
26
 
25
27
  spec.files = Dir.chdir(__dir__) do
26
28
  `git ls-files -z`.split("\x0").reject do |f|
27
29
  (File.expand_path(f) == __FILE__) ||
28
- f.start_with?("bin/", "test/", "spec/", ".git", ".github", "Gemfile")
30
+ f.start_with?("bin/", "test/", "spec/", ".git", ".github", "gemfiles/", ".rubocop", "Gemfile")
29
31
  end
30
32
  end
31
33
 
32
34
  spec.require_paths = ["lib"]
33
35
 
34
- spec.add_dependency "activesupport", ">= 7.0", "< 9.0"
35
- spec.add_dependency "faraday", ">= 1.0", "< 3.0"
36
+ spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
37
+ spec.add_dependency "csv", ">= 3.0"
38
+ spec.add_dependency "faraday", ">= 2.0", "< 3.0"
36
39
 
37
- spec.add_development_dependency "activerecord", ">= 7.0", "< 9.0"
40
+ spec.add_development_dependency "activerecord", ">= 7.1", "< 9.0"
41
+ spec.add_development_dependency "railties", ">= 7.1", "< 9.0"
38
42
  spec.add_development_dependency "rake", "~> 13.0"
39
43
  spec.add_development_dependency "rspec", "~> 3.0"
40
44
  spec.add_development_dependency "rubocop", "~> 1.0"
41
- spec.add_development_dependency "sqlite3", "~> 2.0"
45
+ spec.add_development_dependency "sqlite3", ">= 1.4", "< 3.0"
42
46
  spec.add_development_dependency "webmock", "~> 3.0"
43
47
  end