llm_cost_tracker 0.2.0.alpha2 → 0.3.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +114 -70
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +760 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  15. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  16. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
  18. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  19. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  20. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  21. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  22. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  23. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  24. data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  26. data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
  27. data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
  28. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  30. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  31. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  32. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  39. data/config/routes.rb +3 -0
  40. data/lib/llm_cost_tracker/assets.rb +19 -0
  41. data/lib/llm_cost_tracker/configuration.rb +78 -42
  42. data/lib/llm_cost_tracker/engine.rb +2 -0
  43. data/lib/llm_cost_tracker/event.rb +2 -0
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  48. data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
  49. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  50. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  51. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  52. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  53. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  54. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  55. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  56. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  57. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  58. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  59. data/lib/llm_cost_tracker/price_registry.rb +18 -7
  60. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  61. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  62. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  63. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  64. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  65. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  66. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  67. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  68. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  69. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  70. data/lib/llm_cost_tracker/pricing.rb +19 -6
  71. data/lib/llm_cost_tracker/retention.rb +34 -0
  72. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  73. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  74. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  75. data/lib/llm_cost_tracker/tags_column.rb +21 -1
  76. data/lib/llm_cost_tracker/tracker.rb +15 -12
  77. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  78. data/lib/llm_cost_tracker/version.rb +1 -1
  79. data/lib/llm_cost_tracker.rb +51 -29
  80. data/lib/tasks/llm_cost_tracker.rake +124 -0
  81. data/llm_cost_tracker.gemspec +9 -8
  82. metadata +40 -12
  83. data/PLAN_0.2.md +0 -488
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "net/http"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module LlmCostTracker
9
+ module PriceSync
10
+ class Fetcher
11
+ Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
12
+ def source_version
13
+ etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
14
+ end
15
+ end
16
+
17
+ USER_AGENT = "llm_cost_tracker price sync"
18
+ MAX_REDIRECTS = 5
19
+ OPEN_TIMEOUT = 5
20
+ READ_TIMEOUT = 10
21
+ WRITE_TIMEOUT = 10
22
+
23
+ def get(url, etag: nil, redirects: 0)
24
+ raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
25
+
26
+ uri = URI.parse(url)
27
+ request = Net::HTTP::Get.new(uri)
28
+ request["User-Agent"] = USER_AGENT
29
+ request["If-None-Match"] = etag if etag
30
+
31
+ response = Net::HTTP.start(
32
+ uri.host,
33
+ uri.port,
34
+ use_ssl: uri.scheme == "https",
35
+ open_timeout: OPEN_TIMEOUT,
36
+ read_timeout: READ_TIMEOUT,
37
+ write_timeout: WRITE_TIMEOUT
38
+ ) do |http|
39
+ http.request(request)
40
+ end
41
+
42
+ case response
43
+ when Net::HTTPSuccess
44
+ build_response(response, not_modified: false)
45
+ when Net::HTTPNotModified
46
+ build_response(response, body: nil, not_modified: true)
47
+ when Net::HTTPRedirection
48
+ location = response["location"]
49
+ raise Error, "Redirect without location while fetching #{url}" if location.nil? || location.empty?
50
+
51
+ get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
52
+ else
53
+ raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
54
+ end
55
+ rescue SocketError, SystemCallError, Timeout::Error => e
56
+ raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ def build_response(response, not_modified:, body: response.body)
62
+ Response.new(
63
+ body: body,
64
+ etag: response["etag"],
65
+ last_modified: response["last-modified"],
66
+ not_modified: not_modified,
67
+ fetched_at: Time.now.utc.iso8601
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class Merger
6
+ Discrepancy = Data.define(:model, :field, :values)
7
+
8
+ PRIORITY_ORDER = %i[litellm openrouter].freeze
9
+ SUPPLEMENTAL_FIELDS = %i[cached_input cache_read_input cache_creation_input].freeze
10
+
11
+ def merge(results_by_source)
12
+ prices = collect_prices(results_by_source)
13
+ discrepancies = []
14
+
15
+ merged = prices.group_by(&:model).sort.to_h.transform_values do |candidates|
16
+ sorted = sort_candidates(candidates)
17
+ discrepancies.concat(detect_discrepancies(sorted))
18
+ fill_missing_fields(sorted.first, sorted.drop(1))
19
+ end
20
+
21
+ [merged, discrepancies]
22
+ end
23
+
24
+ private
25
+
26
+ def collect_prices(results_by_source)
27
+ results_by_source.flat_map do |source_name, result|
28
+ result.prices.map do |price|
29
+ price.with(source: source_name)
30
+ end
31
+ end
32
+ end
33
+
34
+ def sort_candidates(candidates)
35
+ candidates.sort_by do |price|
36
+ PRIORITY_ORDER.index(price.source.to_sym) || PRIORITY_ORDER.length
37
+ end
38
+ end
39
+
40
+ def fill_missing_fields(primary, fallbacks)
41
+ SUPPLEMENTAL_FIELDS.reduce(primary) do |current, field|
42
+ next current if current.public_send(field)
43
+
44
+ fallback = fallbacks.find { |candidate| candidate.public_send(field) }
45
+ fallback ? current.with(field => fallback.public_send(field)) : current
46
+ end
47
+ end
48
+
49
+ def detect_discrepancies(candidates)
50
+ return [] if candidates.length < 2
51
+
52
+ RawPrice::PRICE_FIELDS.filter_map do |field|
53
+ values = candidates.each_with_object({}) do |price, collected|
54
+ value = price.public_send(field)
55
+ collected[price.source] = value unless value.nil?
56
+ end
57
+ next if values.size < 2
58
+ next unless discrepant?(values.values)
59
+
60
+ Discrepancy.new(model: candidates.first.model, field: field, values: values)
61
+ end
62
+ end
63
+
64
+ def discrepant?(values)
65
+ min, max = values.minmax
66
+ return max != min if min.to_f.zero?
67
+
68
+ ((max - min).abs / min.to_f) >= 0.05
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class ModelCatalog
6
+ OPENROUTER_PROVIDER_PREFIXES = {
7
+ openai: %w[openai],
8
+ anthropic: %w[anthropic],
9
+ gemini: %w[google]
10
+ }.freeze
11
+ LITELLM_PROVIDER_PREFIXES = {
12
+ openai: [nil, "openai"],
13
+ anthropic: [nil, "anthropic"],
14
+ gemini: [nil, "gemini"]
15
+ }.freeze
16
+ ALIASES = {
17
+ "gpt-4o-2024-05-13" => "gpt-4o"
18
+ }.freeze
19
+
20
+ class << self
21
+ def resolve_from_litellm(our_model, payload)
22
+ litellm_candidates(our_model).find { |candidate| payload.key?(candidate) }
23
+ end
24
+
25
+ def resolve_from_openrouter(our_model, index)
26
+ openrouter_candidates(our_model).find { |candidate| index.key?(candidate) }
27
+ end
28
+
29
+ def guess_provider(our_model)
30
+ case our_model.to_s
31
+ when /\A(?:gpt-|o1|o3|o4|chatgpt|text-embedding)/
32
+ :openai
33
+ when /\Aclaude-/
34
+ :anthropic
35
+ when /\Agemini-/
36
+ :gemini
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def litellm_candidates(our_model)
43
+ provider = guess_provider(our_model)
44
+ prefixes = LITELLM_PROVIDER_PREFIXES.fetch(provider, [nil])
45
+
46
+ model_variants(our_model).flat_map do |variant|
47
+ prefixes.map { |prefix| prefix ? "#{prefix}/#{variant}" : variant }
48
+ end.uniq
49
+ end
50
+
51
+ def openrouter_candidates(our_model)
52
+ provider = guess_provider(our_model)
53
+ prefixes = OPENROUTER_PROVIDER_PREFIXES.fetch(provider, [])
54
+
55
+ model_variants(our_model).flat_map do |variant|
56
+ prefixes.map { |prefix| "#{prefix}/#{variant}" }
57
+ end.uniq
58
+ end
59
+
60
+ def model_variants(our_model)
61
+ model = our_model.to_s
62
+ canonical = ALIASES.fetch(model, model)
63
+
64
+ [model, canonical].flat_map do |variant|
65
+ [variant, anthropic_version_variant(variant)]
66
+ end.compact.uniq
67
+ end
68
+
69
+ def anthropic_version_variant(model)
70
+ return nil unless guess_provider(model) == :anthropic
71
+
72
+ model.gsub(/(?<=\d)-(?=\d)/, ".")
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ RawPrice = Data.define(
6
+ :model,
7
+ :provider,
8
+ :input,
9
+ :output,
10
+ :cached_input,
11
+ :cache_read_input,
12
+ :cache_creation_input,
13
+ :source,
14
+ :source_version,
15
+ :fetched_at
16
+ )
17
+
18
+ class RawPrice
19
+ PRICE_FIELDS = %w[input output cached_input cache_read_input cache_creation_input].freeze
20
+
21
+ def to_registry_entry(today:)
22
+ {
23
+ "input" => input,
24
+ "output" => output,
25
+ "cached_input" => cached_input,
26
+ "cache_read_input" => cache_read_input,
27
+ "cache_creation_input" => cache_creation_input,
28
+ "_source" => source.to_s,
29
+ "_source_version" => source_version,
30
+ "_fetched_at" => fetched_at || today.iso8601
31
+ }.compact
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class Source
6
+ def fetch(current_models:, fetcher:)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def name
11
+ self.class.name.split("::").last.downcase.to_sym
12
+ end
13
+
14
+ def priority
15
+ 100
16
+ end
17
+
18
+ def url
19
+ raise NotImplementedError
20
+ end
21
+
22
+ private
23
+
24
+ def response_version(response)
25
+ response.source_version
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ SourceResult = Data.define(:prices, :missing_models, :source_version)
6
+ end
7
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module PriceSync
7
+ module Sources
8
+ class Litellm < Source
9
+ PER_TOKEN_TO_PER_MILLION = 1_000_000
10
+ SUPPORTED_MODES = %w[chat completion embedding responses].freeze
11
+ SUPPORTED_PROVIDERS = %w[openai anthropic gemini text-completion-openai].freeze
12
+ URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
13
+
14
+ def priority
15
+ 10
16
+ end
17
+
18
+ def url
19
+ URL
20
+ end
21
+
22
+ def fetch(current_models:, fetcher:)
23
+ response = fetcher.get(url)
24
+ payload = JSON.parse(response.body.to_s)
25
+
26
+ prices = []
27
+ missing_models = []
28
+
29
+ current_models.each_key do |our_model|
30
+ entry_id = ModelCatalog.resolve_from_litellm(our_model, payload)
31
+ entry = entry_id && payload[entry_id]
32
+
33
+ if entry && supported_entry?(entry)
34
+ prices << build_raw_price(our_model, entry, response)
35
+ else
36
+ missing_models << our_model
37
+ end
38
+ end
39
+
40
+ SourceResult.new(
41
+ prices: prices,
42
+ missing_models: missing_models.sort,
43
+ source_version: response_version(response)
44
+ )
45
+ rescue JSON::ParserError => e
46
+ raise Error, "Unable to parse #{url}: #{e.message}"
47
+ end
48
+
49
+ private
50
+
51
+ def supported_entry?(entry)
52
+ SUPPORTED_PROVIDERS.include?(entry["litellm_provider"]) &&
53
+ SUPPORTED_MODES.include?(entry["mode"]) &&
54
+ entry.key?("input_cost_per_token") &&
55
+ entry.key?("output_cost_per_token")
56
+ end
57
+
58
+ def build_raw_price(model, entry, response)
59
+ provider = normalize_provider(entry["litellm_provider"])
60
+ cache_read = price_per_million(entry["cache_read_input_token_cost"])
61
+ cache_write = price_per_million(entry["cache_creation_input_token_cost"])
62
+
63
+ RawPrice.new(
64
+ model: model,
65
+ provider: provider,
66
+ input: price_per_million(entry["input_cost_per_token"]),
67
+ output: price_per_million(entry["output_cost_per_token"]),
68
+ cached_input: provider == "anthropic" ? nil : cache_read,
69
+ cache_read_input: provider == "anthropic" ? cache_read : nil,
70
+ cache_creation_input: provider == "anthropic" ? cache_write : nil,
71
+ source: name,
72
+ source_version: response_version(response),
73
+ fetched_at: response.fetched_at
74
+ )
75
+ end
76
+
77
+ def normalize_provider(provider)
78
+ return "openai" if provider == "text-completion-openai"
79
+
80
+ provider
81
+ end
82
+
83
+ def price_per_million(value)
84
+ return nil if value.nil?
85
+
86
+ value.to_f * PER_TOKEN_TO_PER_MILLION
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module PriceSync
7
+ module Sources
8
+ class OpenRouter < Source
9
+ PER_TOKEN_TO_PER_MILLION = 1_000_000
10
+ SUPPORTED_PREFIXES = %w[openai anthropic google].freeze
11
+ URL = "https://openrouter.ai/api/v1/models"
12
+
13
+ def priority
14
+ 20
15
+ end
16
+
17
+ def url
18
+ URL
19
+ end
20
+
21
+ def fetch(current_models:, fetcher:)
22
+ response = fetcher.get(url)
23
+ payload = JSON.parse(response.body.to_s)
24
+ index = payload.fetch("data", []).to_h { |entry| [entry["id"], entry] }
25
+
26
+ prices = []
27
+ missing_models = []
28
+
29
+ current_models.each_key do |our_model|
30
+ entry_id = ModelCatalog.resolve_from_openrouter(our_model, index)
31
+ entry = entry_id && index[entry_id]
32
+
33
+ if entry && supported_entry?(entry)
34
+ prices << build_raw_price(our_model, entry, response)
35
+ else
36
+ missing_models << our_model
37
+ end
38
+ end
39
+
40
+ SourceResult.new(
41
+ prices: prices,
42
+ missing_models: missing_models.sort,
43
+ source_version: response_version(response)
44
+ )
45
+ rescue JSON::ParserError => e
46
+ raise Error, "Unable to parse #{url}: #{e.message}"
47
+ end
48
+
49
+ private
50
+
51
+ def supported_entry?(entry)
52
+ pricing = entry["pricing"] || {}
53
+ provider = entry["id"].to_s.split("/").first
54
+
55
+ SUPPORTED_PREFIXES.include?(provider) &&
56
+ pricing["prompt"] &&
57
+ pricing["completion"]
58
+ end
59
+
60
+ def build_raw_price(model, entry, response)
61
+ pricing = entry.fetch("pricing", {})
62
+ provider = normalize_provider(entry.fetch("id").split("/").first)
63
+ cache_read = price_per_million(pricing["input_cache_read"])
64
+ cache_write = price_per_million(pricing["input_cache_write"])
65
+
66
+ RawPrice.new(
67
+ model: model,
68
+ provider: provider,
69
+ input: price_per_million(pricing["prompt"]),
70
+ output: price_per_million(pricing["completion"]),
71
+ cached_input: provider == "anthropic" ? nil : cache_read,
72
+ cache_read_input: provider == "anthropic" ? cache_read : nil,
73
+ cache_creation_input: provider == "anthropic" ? cache_write : nil,
74
+ source: name,
75
+ source_version: response_version(response),
76
+ fetched_at: response.fetched_at
77
+ )
78
+ end
79
+
80
+ def normalize_provider(provider)
81
+ return "gemini" if provider == "google"
82
+
83
+ provider
84
+ end
85
+
86
+ def price_per_million(value)
87
+ return nil if value.nil?
88
+
89
+ value.to_f * PER_TOKEN_TO_PER_MILLION
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class Validator
6
+ Result = Data.define(:accepted, :rejected, :flagged)
7
+ Issue = Data.define(:model, :reason, :old_price, :new_price)
8
+
9
+ MAX_INPUT_PER_MILLION = 100.0
10
+ MAX_OUTPUT_PER_MILLION = 500.0
11
+ MAX_RELATIVE_CHANGE = 3.0
12
+
13
+ def validate_batch(merged_prices, existing_registry:)
14
+ merged_prices.each_with_object(Result.new(accepted: {}, rejected: [], flagged: [])) do |(model, price), result|
15
+ old_price = normalize_entry(existing_registry[model])
16
+ status, reason = validate(new_price: price, old_price: old_price)
17
+
18
+ case status
19
+ when :rejected
20
+ result.rejected << Issue.new(model: model, reason: reason, old_price: old_price, new_price: price)
21
+ when :flagged
22
+ result.flagged << Issue.new(model: model, reason: reason, old_price: old_price, new_price: price)
23
+ result.accepted[model] = price
24
+ else
25
+ result.accepted[model] = price
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def validate(new_price:, old_price:)
33
+ overrides = Array(normalize_entry(old_price)["_validator_override"])
34
+
35
+ return [:rejected, "input > $#{MAX_INPUT_PER_MILLION}/1M"] if new_price.input > MAX_INPUT_PER_MILLION
36
+ return [:rejected, "output > $#{MAX_OUTPUT_PER_MILLION}/1M"] if new_price.output > MAX_OUTPUT_PER_MILLION
37
+ return [:ok, nil] if overrides.include?("skip_relative_change")
38
+
39
+ if old_price.any? && changed_too_much?(old_price, new_price)
40
+ return [:flagged, "price changed >#{MAX_RELATIVE_CHANGE}x"]
41
+ end
42
+
43
+ [:ok, nil]
44
+ end
45
+
46
+ def changed_too_much?(old_price, new_price)
47
+ %i[input output].any? do |field|
48
+ old_value = old_price[field.to_s].to_f
49
+ next false if old_value.zero?
50
+
51
+ new_value = new_price.public_send(field).to_f
52
+ next false if new_value.zero?
53
+
54
+ ratio = [new_value / old_value, old_value / new_value].max
55
+ ratio > MAX_RELATIVE_CHANGE
56
+ end
57
+ end
58
+
59
+ def normalize_entry(entry)
60
+ (entry || {}).each_with_object({}) do |(key, value), normalized|
61
+ normalized[key.to_s] = value
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end