llm_cost_tracker 0.3.0 → 0.3.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/CODE_OF_CONDUCT.md +23 -0
  4. data/README.md +86 -8
  5. data/SECURITY.md +36 -0
  6. data/app/assets/llm_cost_tracker/application.css +1 -4
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +0 -2
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  10. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  11. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  12. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  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 +10 -10
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
  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 +13 -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 +1 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
  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/configuration.rb +0 -1
  31. data/lib/llm_cost_tracker/event.rb +1 -0
  32. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
  36. data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
  37. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
  38. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  39. data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
  40. data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
  41. data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
  42. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
  43. data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
  44. data/lib/llm_cost_tracker/price_registry.rb +22 -7
  45. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  46. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  47. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  48. data/lib/llm_cost_tracker/price_sync.rb +16 -184
  49. data/lib/llm_cost_tracker/pricing.rb +0 -11
  50. data/lib/llm_cost_tracker/railtie.rb +2 -1
  51. data/lib/llm_cost_tracker/report.rb +0 -5
  52. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
  53. data/lib/llm_cost_tracker/stream_collector.rb +17 -13
  54. data/lib/llm_cost_tracker/tags_column.rb +4 -0
  55. data/lib/llm_cost_tracker/tracker.rb +10 -2
  56. data/lib/llm_cost_tracker/version.rb +1 -1
  57. data/lib/llm_cost_tracker.rb +6 -14
  58. data/llm_cost_tracker.gemspec +3 -1
  59. metadata +37 -1
@@ -35,7 +35,12 @@ module LlmCostTracker
35
35
  usage = response["usageMetadata"]
36
36
  return nil unless usage
37
37
 
38
- build_parsed_usage(request_url, usage, usage_source: :response)
38
+ build_parsed_usage(
39
+ request_url,
40
+ usage,
41
+ usage_source: :response,
42
+ provider_response_id: response["responseId"]
43
+ )
39
44
  end
40
45
 
41
46
  def parse_stream(request_url, _request_body, response_status, events)
@@ -45,10 +50,17 @@ module LlmCostTracker
45
50
  model = extract_model_from_url(request_url)
46
51
 
47
52
  if usage
48
- build_parsed_usage(request_url, usage, stream: true, usage_source: :stream_final)
53
+ build_parsed_usage(
54
+ request_url,
55
+ usage,
56
+ stream: true,
57
+ usage_source: :stream_final,
58
+ provider_response_id: stream_response_id(events)
59
+ )
49
60
  else
50
61
  ParsedUsage.build(
51
62
  provider: "gemini",
63
+ provider_response_id: stream_response_id(events),
52
64
  model: model,
53
65
  input_tokens: 0,
54
66
  output_tokens: 0,
@@ -61,7 +73,7 @@ module LlmCostTracker
61
73
 
62
74
  private
63
75
 
64
- def build_parsed_usage(request_url, usage, usage_source:, stream: false)
76
+ def build_parsed_usage(request_url, usage, usage_source:, stream: false, provider_response_id: nil)
65
77
  ParsedUsage.build(
66
78
  provider: "gemini",
67
79
  model: extract_model_from_url(request_url),
@@ -70,7 +82,8 @@ module LlmCostTracker
70
82
  total_tokens: usage["totalTokenCount"].to_i,
71
83
  cached_input_tokens: usage["cachedContentTokenCount"],
72
84
  stream: stream,
73
- usage_source: usage_source
85
+ usage_source: usage_source,
86
+ provider_response_id: provider_response_id
74
87
  )
75
88
  end
76
89
 
@@ -90,6 +103,17 @@ module LlmCostTracker
90
103
  usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
91
104
  end
92
105
 
106
+ def stream_response_id(events)
107
+ events.each do |event|
108
+ data = event[:data]
109
+ next unless data.is_a?(Hash)
110
+
111
+ id = data["responseId"]
112
+ return id if id && !id.to_s.empty?
113
+ end
114
+ nil
115
+ end
116
+
93
117
  def streaming_url?(request_url)
94
118
  URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
95
119
  rescue URI::InvalidURIError
@@ -20,7 +20,10 @@ module LlmCostTracker
20
20
  end
21
21
 
22
22
  def provider_names
23
- ["openai_compatible", *configured_providers.each_value.map(&:to_s)].uniq.freeze
23
+ [
24
+ "openai_compatible",
25
+ *LlmCostTracker.configuration.openai_compatible_providers.each_value.map(&:to_s)
26
+ ].uniq.freeze
24
27
  end
25
28
 
26
29
  def parse(request_url, request_body, response_status, response_body)
@@ -41,11 +44,7 @@ module LlmCostTracker
41
44
  end
42
45
 
43
46
  def provider_for_host(host)
44
- configured_providers[host.to_s.downcase]&.to_s
45
- end
46
-
47
- def configured_providers
48
- LlmCostTracker.configuration.openai_compatible_providers
47
+ LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
49
48
  end
50
49
 
51
50
  def tracked_path?(path)
@@ -16,6 +16,7 @@ module LlmCostTracker
16
16
 
17
17
  ParsedUsage.build(
18
18
  provider: provider_for(request_url),
19
+ provider_response_id: response["id"],
19
20
  model: response["model"] || request["model"],
20
21
  input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
21
22
  output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
@@ -35,6 +36,7 @@ module LlmCostTracker
35
36
  if usage
36
37
  ParsedUsage.build(
37
38
  provider: provider_for(request_url),
39
+ provider_response_id: detect_stream_response_id(events),
38
40
  model: model,
39
41
  input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
40
42
  output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
@@ -46,6 +48,7 @@ module LlmCostTracker
46
48
  else
47
49
  ParsedUsage.build(
48
50
  provider: provider_for(request_url),
51
+ provider_response_id: detect_stream_response_id(events),
49
52
  model: model,
50
53
  input_tokens: 0,
51
54
  output_tokens: 0,
@@ -78,6 +81,17 @@ module LlmCostTracker
78
81
  nil
79
82
  end
80
83
 
84
+ def detect_stream_response_id(events)
85
+ events.each do |event|
86
+ data = event[:data]
87
+ next unless data.is_a?(Hash)
88
+
89
+ id = data["id"] || data.dig("response", "id")
90
+ return id if id && !id.to_s.empty?
91
+ end
92
+ nil
93
+ end
94
+
81
95
  def cached_input_tokens(usage)
82
96
  details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
83
97
  details["cached_tokens"]
@@ -26,9 +26,7 @@ module LlmCostTracker
26
26
  end
27
27
 
28
28
  def normalize_price_table(table)
29
- (table || {}).each_with_object({}) do |(model, price), normalized|
30
- normalized[model.to_s] = normalize_price_entry(price)
31
- end
29
+ normalize_price_entries(table, context: "price table")
32
30
  end
33
31
 
34
32
  def file_prices(path)
@@ -47,7 +45,7 @@ module LlmCostTracker
47
45
  @file_prices_cache = { key: cache_key, value: value }.freeze
48
46
  value
49
47
  end
50
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
48
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
51
49
  raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
52
50
  end
53
51
 
@@ -60,15 +58,23 @@ module LlmCostTracker
60
58
  end
61
59
 
62
60
  def normalize_price_entry(price)
63
- (price || {}).each_with_object({}) do |(key, value), normalized|
61
+ price.each_with_object({}) do |(key, value), normalized|
64
62
  key = key.to_s
65
63
  normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
66
64
  end
67
65
  end
68
66
 
69
67
  def normalize_file_prices(table, path:)
70
- (table || {}).each_with_object({}) do |(model, price), normalized|
71
- warn_unknown_keys(model, price, path)
68
+ normalize_price_entries(table, context: path)
69
+ end
70
+
71
+ def normalize_price_entries(table, context:)
72
+ table = {} if table.nil?
73
+ raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
74
+
75
+ table.each_with_object({}) do |(model, price), normalized|
76
+ price = validate_price_entry(price, model: model, context: context)
77
+ warn_unknown_keys(model, price, context)
72
78
  normalized[model.to_s] = normalize_price_entry(price)
73
79
  end
74
80
  end
@@ -95,8 +101,17 @@ module LlmCostTracker
95
101
  end
96
102
 
97
103
  def price_file_models(registry)
104
+ raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
105
+
98
106
  registry.fetch("models", registry)
99
107
  end
108
+
109
+ def validate_price_entry(price, model:, context:)
110
+ return {} if price.nil?
111
+ return price if price.is_a?(Hash)
112
+
113
+ raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
114
+ end
100
115
  end
101
116
  end
102
117
  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
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
- require "fileutils"
5
- require "json"
6
- require "yaml"
7
4
 
8
5
  require_relative "price_sync/fetcher"
9
6
  require_relative "price_sync/raw_price"
10
7
  require_relative "price_sync/source"
11
8
  require_relative "price_sync/source_result"
9
+ require_relative "price_sync/registry_loader"
10
+ require_relative "price_sync/registry_writer"
11
+ require_relative "price_sync/refresh_plan_builder"
12
12
  require_relative "price_sync/model_catalog"
13
13
  require_relative "price_sync/merger"
14
14
  require_relative "price_sync/validator"
@@ -16,10 +16,8 @@ require_relative "price_sync/sources/litellm"
16
16
  require_relative "price_sync/sources/open_router"
17
17
 
18
18
  module LlmCostTracker
19
- # rubocop:disable Metrics/ModuleLength, Metrics/ClassLength
20
19
  module PriceSync
21
20
  DEFAULT_OUTPUT_PATH = PriceRegistry::DEFAULT_PRICES_PATH
22
- YAML_EXTENSIONS = %w[.yml .yaml].freeze
23
21
 
24
22
  SourceUsage = Data.define(:prices_count, :source_version)
25
23
  SyncResult = Data.define(
@@ -71,11 +69,16 @@ module LlmCostTracker
71
69
  class << self
72
70
  def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
73
71
  fetcher: Fetcher.new, today: Date.today)
74
- plan = build_refresh_plan(path: path, seed_path: seed_path, fetcher: fetcher, today: today)
72
+ plan = RefreshPlanBuilder.new(sources: sources).call(
73
+ path: path,
74
+ seed_path: seed_path,
75
+ fetcher: fetcher,
76
+ today: today
77
+ )
75
78
  raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
76
79
 
77
80
  written = !preview && plan.refresh_succeeded?
78
- write_registry(plan.path, plan.updated_registry) if written
81
+ RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
79
82
 
80
83
  SyncResult.new(
81
84
  path: plan.path,
@@ -92,7 +95,12 @@ module LlmCostTracker
92
95
  end
93
96
 
94
97
  def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
95
- plan = build_refresh_plan(path: path, seed_path: seed_path, fetcher: fetcher, today: today)
98
+ plan = RefreshPlanBuilder.new(sources: sources).call(
99
+ path: path,
100
+ seed_path: seed_path,
101
+ fetcher: fetcher,
102
+ today: today
103
+ )
96
104
 
97
105
  CheckResult.new(
98
106
  path: plan.path,
@@ -113,166 +121,6 @@ module LlmCostTracker
113
121
  [Sources::Litellm.new, Sources::OpenRouter.new]
114
122
  end
115
123
 
116
- def build_refresh_plan(path:, seed_path:, fetcher:, today:)
117
- path = path.to_s
118
- registry = load_registry(path, seed_path: seed_path)
119
- current_models = registry.fetch("models", {})
120
- source_results, failed_sources = fetch_all(current_models, fetcher)
121
- merged, discrepancies = Merger.new.merge(source_results)
122
- validated = Validator.new.validate_batch(merged, existing_registry: current_models)
123
- updated_models = apply_changes(current_models, validated.accepted, today)
124
- refresh_succeeded = source_results.any? { |_source, result| result.prices.any? }
125
-
126
- RefreshPlan.new(
127
- path: path,
128
- registry: registry,
129
- updated_registry: registry.merge(
130
- "metadata" => updated_metadata(
131
- registry["metadata"],
132
- today,
133
- refresh_succeeded: refresh_succeeded,
134
- source_results: source_results
135
- ),
136
- "models" => updated_models
137
- ),
138
- accepted: validated.accepted,
139
- changes: price_changes(current_models, updated_models),
140
- orphaned_models: compute_orphaned(current_models, merged.keys),
141
- failed_sources: failed_sources,
142
- discrepancies: discrepancies,
143
- rejected: validated.rejected,
144
- flagged: validated.flagged,
145
- sources_used: source_usage(source_results),
146
- source_results: source_results
147
- )
148
- end
149
-
150
- def fetch_all(current_models, fetcher)
151
- results = {}
152
- failures = {}
153
-
154
- sources.each do |source|
155
- results[source.name.to_sym] = source.fetch(current_models: current_models, fetcher: fetcher)
156
- rescue Error => e
157
- failures[source.name.to_sym] = e.message
158
- end
159
-
160
- [results, failures]
161
- end
162
-
163
- def apply_changes(current_models, accepted, today)
164
- merged = seed_models(current_models)
165
-
166
- accepted.each do |model, price|
167
- next if manual_model?(merged[model])
168
-
169
- merged[model] = registry_entry_for(merged[model], price, today)
170
- end
171
-
172
- merged.sort.to_h
173
- end
174
-
175
- def compute_orphaned(current_models, merged_models)
176
- seed_models(current_models).keys.reject do |model|
177
- manual_model?(current_models[model]) || merged_models.include?(model)
178
- end.sort
179
- end
180
-
181
- def load_registry(path, seed_path:)
182
- source_path = File.exist?(path) ? path : seed_path.to_s
183
- normalize_registry(load_registry_file(source_path))
184
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
185
- raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
186
- end
187
-
188
- def load_registry_file(path)
189
- contents = File.read(path)
190
- return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
191
-
192
- JSON.parse(contents)
193
- end
194
-
195
- def normalize_registry(registry)
196
- {
197
- "metadata" => normalize_hash(registry.fetch("metadata", {})),
198
- "models" => normalize_models(registry.fetch("models", {}))
199
- }
200
- end
201
-
202
- def normalize_models(models)
203
- (models || {}).each_with_object({}) do |(model, entry), normalized|
204
- normalized[model.to_s] = normalize_hash(entry)
205
- end
206
- end
207
-
208
- def normalize_hash(hash)
209
- (hash || {}).each_with_object({}) do |(key, value), normalized|
210
- normalized[key.to_s] = value
211
- end
212
- end
213
-
214
- def seed_models(current_models)
215
- normalize_models(current_models).transform_values do |entry|
216
- next entry if entry.key?("_source")
217
-
218
- entry.merge("_source" => "seed")
219
- end
220
- end
221
-
222
- def manual_model?(entry)
223
- normalize_hash(entry)["_source"] == "manual"
224
- end
225
-
226
- def registry_entry_for(existing_entry, price, today)
227
- normalize_hash(existing_entry)
228
- .except(*PriceRegistry::PRICE_KEYS)
229
- .merge(price.to_registry_entry(today: today))
230
- end
231
-
232
- def updated_metadata(existing, today, refresh_succeeded:, source_results:)
233
- metadata = normalize_hash(existing)
234
- metadata["currency"] ||= "USD"
235
- metadata["unit"] ||= "1M tokens"
236
- return metadata unless refresh_succeeded
237
-
238
- metadata["updated_at"] = today.iso8601
239
- metadata["source_urls"] = source_urls(source_results)
240
- metadata
241
- end
242
-
243
- def source_usage(source_results)
244
- source_results.transform_values do |result|
245
- SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
246
- end
247
- end
248
-
249
- def price_changes(current_models, updated_models)
250
- current_models = normalize_models(current_models)
251
- updated_models = normalize_models(updated_models)
252
-
253
- (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
254
- fields = price_field_changes(current_models[model], updated_models[model])
255
- changes[model] = fields if fields.any?
256
- end
257
- end
258
-
259
- def price_field_changes(current_entry, updated_entry)
260
- current_price = comparable_price(current_entry)
261
- updated_price = comparable_price(updated_entry)
262
-
263
- (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
264
- from = current_price[field]
265
- to = updated_price[field]
266
- next if from == to
267
-
268
- changes[field] = { "from" => from, "to" => to }
269
- end
270
- end
271
-
272
- def comparable_price(entry)
273
- normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
274
- end
275
-
276
124
  def strict_sync_failure?(plan, strict:)
277
125
  strict && (plan.failed_sources.any? || plan.rejected.any?)
278
126
  end
@@ -289,22 +137,6 @@ module LlmCostTracker
289
137
  end
290
138
  "Price sync failed in strict mode: #{messages.join('; ')}"
291
139
  end
292
-
293
- def source_urls(source_results)
294
- names = source_results.keys.map(&:to_sym)
295
- sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
296
- end
297
-
298
- def write_registry(path, registry)
299
- FileUtils.mkdir_p(File.dirname(path))
300
- payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
301
- File.write(path, payload)
302
- end
303
-
304
- def yaml_file?(path)
305
- YAML_EXTENSIONS.include?(File.extname(path).downcase)
306
- end
307
140
  end
308
141
  end
309
- # rubocop:enable Metrics/ModuleLength, Metrics/ClassLength
310
142
  end