llm_cost_tracker 0.2.0 → 0.3.1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +124 -68
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +1 -4
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  10. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
  17. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  19. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +23 -13
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +11 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
  25. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  26. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  27. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  28. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  29. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  30. data/lib/llm_cost_tracker/assets.rb +6 -11
  31. data/lib/llm_cost_tracker/configuration.rb +78 -43
  32. data/lib/llm_cost_tracker/event.rb +3 -0
  33. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  40. data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
  41. data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
  42. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  43. data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
  44. data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
  45. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  46. data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
  47. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  48. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
  50. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  51. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +23 -8
  53. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  54. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  55. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  56. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  57. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  58. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  59. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  60. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  61. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  62. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  63. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  64. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  65. data/lib/llm_cost_tracker/price_sync.rb +142 -0
  66. data/lib/llm_cost_tracker/pricing.rb +0 -11
  67. data/lib/llm_cost_tracker/railtie.rb +0 -1
  68. data/lib/llm_cost_tracker/report.rb +0 -5
  69. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
  70. data/lib/llm_cost_tracker/stream_collector.rb +162 -0
  71. data/lib/llm_cost_tracker/tags_column.rb +12 -0
  72. data/lib/llm_cost_tracker/tracker.rb +23 -12
  73. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. data/lib/llm_cost_tracker.rb +48 -35
  76. data/lib/tasks/llm_cost_tracker.rake +116 -0
  77. data/llm_cost_tracker.gemspec +8 -6
  78. metadata +30 -8
@@ -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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class RefreshPlanBuilder
6
+ def initialize(sources:, loader: RegistryLoader.new)
7
+ @sources = sources
8
+ @loader = loader
9
+ end
10
+
11
+ def call(path:, seed_path:, fetcher:, today:)
12
+ path = path.to_s
13
+ registry = loader.call(path: path, seed_path: seed_path)
14
+ current_models = registry.fetch("models", {})
15
+ source_results, failed_sources = fetch_all(current_models, fetcher)
16
+ merged, discrepancies = Merger.new.merge(source_results)
17
+ validated = Validator.new.validate_batch(merged, existing_registry: current_models)
18
+ updated_models = apply_changes(current_models, validated.accepted, today)
19
+
20
+ PriceSync::RefreshPlan.new(
21
+ path: path,
22
+ registry: registry,
23
+ updated_registry: registry.merge(
24
+ "metadata" => updated_metadata(
25
+ registry["metadata"],
26
+ today,
27
+ refresh_succeeded: source_results.any? { |_source, result| result.prices.any? },
28
+ source_results: source_results
29
+ ),
30
+ "models" => updated_models
31
+ ),
32
+ accepted: validated.accepted,
33
+ changes: price_changes(current_models, updated_models),
34
+ orphaned_models: compute_orphaned(current_models, merged.keys),
35
+ failed_sources: failed_sources,
36
+ discrepancies: discrepancies,
37
+ rejected: validated.rejected,
38
+ flagged: validated.flagged,
39
+ sources_used: source_usage(source_results),
40
+ source_results: source_results
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :sources, :loader
47
+
48
+ def fetch_all(current_models, fetcher)
49
+ results = {}
50
+ failures = {}
51
+
52
+ sources.each do |source|
53
+ results[source.name.to_sym] = source.fetch(current_models: current_models, fetcher: fetcher)
54
+ rescue Error => e
55
+ failures[source.name.to_sym] = e.message
56
+ end
57
+
58
+ [results, failures]
59
+ end
60
+
61
+ def apply_changes(current_models, accepted, today)
62
+ merged = seed_models(current_models)
63
+
64
+ accepted.each do |model, price|
65
+ next if manual_model?(merged[model])
66
+
67
+ merged[model] = registry_entry_for(merged[model], price, today)
68
+ end
69
+
70
+ merged.sort.to_h
71
+ end
72
+
73
+ def compute_orphaned(current_models, merged_models)
74
+ seed_models(current_models).keys.reject do |model|
75
+ manual_model?(current_models[model]) || merged_models.include?(model)
76
+ end.sort
77
+ end
78
+
79
+ def seed_models(current_models)
80
+ normalize_models(current_models).transform_values do |entry|
81
+ next entry if entry.key?("_source")
82
+
83
+ entry.merge("_source" => "seed")
84
+ end
85
+ end
86
+
87
+ def normalize_models(models)
88
+ normalize_hash(models).each_with_object({}) do |(model, entry), normalized|
89
+ normalized[model.to_s] = normalize_hash(entry)
90
+ end
91
+ end
92
+
93
+ def normalize_hash(hash)
94
+ return {} if hash.nil?
95
+ raise ArgumentError, "price sync entries must be hashes" unless hash.is_a?(Hash)
96
+
97
+ hash.each_with_object({}) do |(key, value), normalized|
98
+ normalized[key.to_s] = value
99
+ end
100
+ end
101
+
102
+ def manual_model?(entry)
103
+ normalize_hash(entry)["_source"] == "manual"
104
+ end
105
+
106
+ def registry_entry_for(existing_entry, price, today)
107
+ normalize_hash(existing_entry)
108
+ .except(*PriceRegistry::PRICE_KEYS)
109
+ .merge(price.to_registry_entry(today: today))
110
+ end
111
+
112
+ def updated_metadata(existing, today, refresh_succeeded:, source_results:)
113
+ metadata = normalize_hash(existing)
114
+ metadata["currency"] ||= "USD"
115
+ metadata["unit"] ||= "1M tokens"
116
+ return metadata unless refresh_succeeded
117
+
118
+ metadata["updated_at"] = today.iso8601
119
+ metadata["source_urls"] = source_urls(source_results)
120
+ metadata
121
+ end
122
+
123
+ def source_usage(source_results)
124
+ source_results.transform_values do |result|
125
+ PriceSync::SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
126
+ end
127
+ end
128
+
129
+ def price_changes(current_models, updated_models)
130
+ current_models = normalize_models(current_models)
131
+ updated_models = normalize_models(updated_models)
132
+
133
+ (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
134
+ fields = price_field_changes(current_models[model], updated_models[model])
135
+ changes[model] = fields if fields.any?
136
+ end
137
+ end
138
+
139
+ def price_field_changes(current_entry, updated_entry)
140
+ current_price = comparable_price(current_entry)
141
+ updated_price = comparable_price(updated_entry)
142
+
143
+ (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
144
+ from = current_price[field]
145
+ to = updated_price[field]
146
+ next if from == to
147
+
148
+ changes[field] = { "from" => from, "to" => to }
149
+ end
150
+ end
151
+
152
+ def comparable_price(entry)
153
+ normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
154
+ end
155
+
156
+ def source_urls(source_results)
157
+ names = source_results.keys.map(&:to_sym)
158
+ sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module LlmCostTracker
7
+ module PriceSync
8
+ class RegistryLoader
9
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
10
+
11
+ def call(path:, seed_path:)
12
+ source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
13
+ normalize_registry(load_registry_file(source_path))
14
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
15
+ raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
16
+ end
17
+
18
+ private
19
+
20
+ def load_registry_file(path)
21
+ contents = File.read(path)
22
+ registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
23
+ raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
24
+
25
+ registry
26
+ end
27
+
28
+ def normalize_registry(registry)
29
+ {
30
+ "metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
31
+ "models" => normalize_models(registry.fetch("models", {}))
32
+ }
33
+ end
34
+
35
+ def normalize_models(models)
36
+ normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
37
+ normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
38
+ end
39
+ end
40
+
41
+ def normalize_hash(hash, label:)
42
+ return {} if hash.nil?
43
+ raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
44
+
45
+ hash.each_with_object({}) do |(key, value), normalized|
46
+ normalized[key.to_s] = value
47
+ end
48
+ end
49
+
50
+ def yaml_file?(path)
51
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ module LlmCostTracker
8
+ module PriceSync
9
+ class RegistryWriter
10
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
11
+
12
+ def call(path:, registry:)
13
+ FileUtils.mkdir_p(File.dirname(path))
14
+ payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
15
+ File.write(path, payload)
16
+ end
17
+
18
+ private
19
+
20
+ def yaml_file?(path)
21
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
22
+ end
23
+ end
24
+ end
25
+ 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