llm_cost_tracker 0.4.1 → 0.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +132 -405
  4. data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
  5. data/lib/llm_cost_tracker/configuration.rb +10 -5
  6. data/lib/llm_cost_tracker/doctor.rb +166 -0
  7. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
  10. data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
  11. data/lib/llm_cost_tracker/integrations/base.rb +72 -0
  12. data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
  13. data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
  14. data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
  15. data/lib/llm_cost_tracker/middleware/faraday.rb +6 -5
  16. data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
  17. data/lib/llm_cost_tracker/parsers/base.rb +1 -1
  18. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  19. data/lib/llm_cost_tracker/price_freshness.rb +38 -0
  20. data/lib/llm_cost_tracker/price_registry.rb +14 -0
  21. data/lib/llm_cost_tracker/price_sync/fetcher.rb +5 -2
  22. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  23. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  24. data/lib/llm_cost_tracker/price_sync.rb +111 -109
  25. data/lib/llm_cost_tracker/prices.json +391 -42
  26. data/lib/llm_cost_tracker/pricing.rb +35 -16
  27. data/lib/llm_cost_tracker/request_url.rb +20 -0
  28. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  29. data/lib/llm_cost_tracker/stream_collector.rb +3 -3
  30. data/lib/llm_cost_tracker/tag_context.rb +52 -0
  31. data/lib/llm_cost_tracker/tracker.rb +7 -60
  32. data/lib/llm_cost_tracker/version.rb +1 -1
  33. data/lib/llm_cost_tracker.rb +14 -4
  34. data/lib/tasks/llm_cost_tracker.rake +33 -69
  35. metadata +28 -12
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
  37. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  38. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  39. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  40. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -162
  41. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  42. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  43. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  44. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  45. data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module LlmCostTracker
6
+ module Integrations
7
+ module Openai
8
+ extend Base
9
+
10
+ class << self
11
+ def integration_name = :openai
12
+
13
+ def target_patches
14
+ [
15
+ [constant("OpenAI::Resources::Responses"), ResponsesPatch],
16
+ [constant("OpenAI::Resources::Chat::Completions"), ChatCompletionsPatch]
17
+ ]
18
+ end
19
+
20
+ def record_response(response, request:, latency_ms:)
21
+ return unless active?
22
+
23
+ record_safely do
24
+ usage = ObjectReader.first(response, :usage)
25
+ next unless usage
26
+
27
+ input_tokens = ObjectReader.first(usage, :input_tokens, :prompt_tokens)
28
+ output_tokens = ObjectReader.first(usage, :output_tokens, :completion_tokens)
29
+ next if input_tokens.nil? && output_tokens.nil?
30
+
31
+ LlmCostTracker::Tracker.record(
32
+ provider: "openai",
33
+ model: ObjectReader.first(response, :model) || request[:model],
34
+ input_tokens: ObjectReader.integer(input_tokens),
35
+ output_tokens: ObjectReader.integer(output_tokens),
36
+ latency_ms: latency_ms,
37
+ usage_source: :sdk_response,
38
+ provider_response_id: ObjectReader.first(response, :id),
39
+ metadata: usage_metadata(usage)
40
+ )
41
+ end
42
+ end
43
+
44
+ def usage_metadata(usage)
45
+ {
46
+ cache_read_input_tokens: cache_read_input_tokens(usage),
47
+ hidden_output_tokens: hidden_output_tokens(usage)
48
+ }
49
+ end
50
+
51
+ def cache_read_input_tokens(usage)
52
+ ObjectReader.integer(
53
+ ObjectReader.nested(usage, :input_tokens_details, :cached_tokens) ||
54
+ ObjectReader.nested(usage, :prompt_tokens_details, :cached_tokens)
55
+ )
56
+ end
57
+
58
+ def hidden_output_tokens(usage)
59
+ ObjectReader.integer(
60
+ ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens) ||
61
+ ObjectReader.nested(usage, :completion_tokens_details, :reasoning_tokens)
62
+ )
63
+ end
64
+ end
65
+
66
+ module ResponsesPatch
67
+ def create(*args, **kwargs)
68
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ LlmCostTracker::Integrations::Openai.enforce_budget!
70
+ response = super
71
+ LlmCostTracker::Integrations::Openai.record_response(
72
+ response,
73
+ request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
74
+ latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
75
+ )
76
+ response
77
+ end
78
+ end
79
+
80
+ module ChatCompletionsPatch
81
+ def create(*args, **kwargs)
82
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ LlmCostTracker::Integrations::Openai.enforce_budget!
84
+ response = super
85
+ LlmCostTracker::Integrations::Openai.record_response(
86
+ response,
87
+ request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
88
+ latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
89
+ )
90
+ response
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai"
4
+ require_relative "anthropic"
5
+
6
+ module LlmCostTracker
7
+ module Integrations
8
+ module Registry
9
+ INTEGRATIONS = {
10
+ openai: Openai,
11
+ anthropic: Anthropic
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def install!(names = LlmCostTracker.configuration.instrumented_integrations)
17
+ normalize(names).each { |name| fetch(name).install }
18
+ end
19
+
20
+ def checks(names = LlmCostTracker.configuration.instrumented_integrations)
21
+ return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
22
+
23
+ normalize(names).map { |name| fetch(name).status }
24
+ end
25
+
26
+ def normalize(names)
27
+ Array(names).flatten.map(&:to_sym).uniq
28
+ end
29
+
30
+ def fetch(name)
31
+ INTEGRATIONS.fetch(name.to_sym) do
32
+ message = "Unknown integration: #{name.inspect}. Use one of: #{INTEGRATIONS.keys.join(', ')}"
33
+ raise LlmCostTracker::Error, message
34
+ end
35
+ end
36
+ end
37
+
38
+ def self.install! = Registry.install!
39
+ def self.checks = Registry.checks
40
+ end
41
+ end
@@ -4,6 +4,7 @@ require "faraday"
4
4
  require "json"
5
5
 
6
6
  require_relative "../logging"
7
+ require_relative "../request_url"
7
8
 
8
9
  module LlmCostTracker
9
10
  module Middleware
@@ -76,9 +77,9 @@ module LlmCostTracker
76
77
  response_body = read_body(response_env.body)
77
78
  unless response_body
78
79
  Logging.warn(
79
- "Unable to read response body for #{request_url}; " \
80
- "streaming responses are captured automatically for OpenAI/Anthropic/Gemini " \
81
- "or via LlmCostTracker.track_stream for custom clients."
80
+ "Unable to read response body for #{RequestUrl.label(request_url)}; " \
81
+ "known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
82
+ "for custom clients."
82
83
  )
83
84
  return nil
84
85
  end
@@ -156,11 +157,11 @@ module LlmCostTracker
156
157
 
157
158
  def capture_warning(request_url, stream_buffer)
158
159
  unless stream_buffer&.dig(:overflowed)
159
- return "Unable to capture streaming response for #{request_url}; " \
160
+ return "Unable to capture streaming response for #{RequestUrl.label(request_url)}; " \
160
161
  "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
161
162
  end
162
163
 
163
- "Streaming response for #{request_url} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
164
+ "Streaming response for #{RequestUrl.label(request_url)} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
164
165
  "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
165
166
  end
166
167
  end
@@ -16,6 +16,7 @@ module LlmCostTracker
16
16
  )
17
17
 
18
18
  class ParsedUsage
19
+ UNKNOWN_MODEL = "unknown"
19
20
  TRACKING_KEYS = %i[
20
21
  provider
21
22
  model
@@ -30,7 +31,7 @@ module LlmCostTracker
30
31
  def self.build(**attributes)
31
32
  new(
32
33
  provider: attributes.fetch(:provider),
33
- model: attributes.fetch(:model),
34
+ model: normalize_model(attributes.fetch(:model)),
34
35
  input_tokens: attributes.fetch(:input_tokens).to_i,
35
36
  output_tokens: attributes.fetch(:output_tokens).to_i,
36
37
  total_tokens: attributes.fetch(:total_tokens, usage_breakdown(attributes).total_tokens).to_i,
@@ -61,5 +62,11 @@ module LlmCostTracker
61
62
  )
62
63
  end
63
64
  private_class_method :usage_breakdown
65
+
66
+ def self.normalize_model(value)
67
+ model = value.to_s.strip
68
+ model.empty? ? UNKNOWN_MODEL : model
69
+ end
70
+ private_class_method :normalize_model
64
71
  end
65
72
  end
@@ -108,7 +108,7 @@ module LlmCostTracker
108
108
  ParsedUsage.build(
109
109
  provider: provider,
110
110
  provider_response_id: provider_response_id,
111
- model: model,
111
+ model: model || ParsedUsage::UNKNOWN_MODEL,
112
112
  input_tokens: 0,
113
113
  output_tokens: 0,
114
114
  total_tokens: 0,
@@ -67,7 +67,7 @@ module LlmCostTracker
67
67
  end
68
68
 
69
69
  def detect_stream_model(events)
70
- find_event_value(events) { |data| data["model"] }
70
+ find_event_value(events) { |data| data["model"] || data.dig("response", "model") }
71
71
  end
72
72
 
73
73
  def detect_stream_response_id(events)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module LlmCostTracker
6
+ module PriceFreshness
7
+ STALE_AFTER_DAYS = 30
8
+
9
+ class << self
10
+ def call(metadata, today: Date.today)
11
+ updated_at = metadata["updated_at"] || metadata[:updated_at]
12
+ return missing unless updated_at
13
+
14
+ date = Date.iso8601(updated_at.to_s)
15
+ age_days = (today - date).to_i
16
+ return stale(updated_at) if age_days > STALE_AFTER_DAYS
17
+
18
+ [:ok, "updated_at=#{updated_at}"]
19
+ rescue Date::Error
20
+ [:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:refresh"]
21
+ end
22
+
23
+ private
24
+
25
+ def missing
26
+ [:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:refresh"]
27
+ end
28
+
29
+ def stale(updated_at)
30
+ [
31
+ :warn,
32
+ "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
33
+ "run bin/rails llm_cost_tracker:prices:refresh"
34
+ ]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -25,6 +25,20 @@ module LlmCostTracker
25
25
  @metadata ||= MUTEX.synchronize { @metadata || raw_registry.fetch("metadata", {}).freeze }
26
26
  end
27
27
 
28
+ def file_metadata(path)
29
+ return {} unless path
30
+
31
+ registry = load_price_file(path.to_s)
32
+ raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
33
+
34
+ metadata = registry.fetch("metadata", {})
35
+ raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
36
+
37
+ metadata
38
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
39
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
40
+ end
41
+
28
42
  def normalize_price_table(table)
29
43
  normalize_price_entries(table, context: "price table")
30
44
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require "net/http"
5
+ require "openssl"
5
6
  require "time"
6
7
  require "uri"
7
8
 
@@ -14,7 +15,7 @@ module LlmCostTracker
14
15
  end
15
16
  end
16
17
 
17
- USER_AGENT = "llm_cost_tracker price sync"
18
+ USER_AGENT = "llm_cost_tracker price refresh"
18
19
  MAX_REDIRECTS = 5
19
20
  OPEN_TIMEOUT = 5
20
21
  READ_TIMEOUT = 10
@@ -24,6 +25,8 @@ module LlmCostTracker
24
25
  raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
25
26
 
26
27
  uri = URI.parse(url)
28
+ raise Error, "Pricing snapshot URL must use http or https" unless %w[http https].include?(uri.scheme)
29
+
27
30
  request = Net::HTTP::Get.new(uri)
28
31
  request["User-Agent"] = USER_AGENT
29
32
  request["If-None-Match"] = etag if etag
@@ -52,7 +55,7 @@ module LlmCostTracker
52
55
  else
53
56
  raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
54
57
  end
55
- rescue SocketError, SystemCallError, Timeout::Error => e
58
+ rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
56
59
  raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
57
60
  end
58
61
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ module RegistryDiff
6
+ class << self
7
+ def call(current_models, updated_models)
8
+ current_models = normalize_models(current_models)
9
+ updated_models = normalize_models(updated_models)
10
+
11
+ (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
12
+ fields = price_field_changes(current_models[model], updated_models[model])
13
+ changes[model] = fields if fields.any?
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def price_field_changes(current_entry, updated_entry)
20
+ current_price = comparable_price(current_entry)
21
+ updated_price = comparable_price(updated_entry)
22
+
23
+ (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
24
+ from = current_price[field]
25
+ to = updated_price[field]
26
+ next if from == to
27
+
28
+ changes[field] = { "from" => from, "to" => to }
29
+ end
30
+ end
31
+
32
+ def comparable_price(entry)
33
+ normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
34
+ end
35
+
36
+ def normalize_models(models)
37
+ normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
38
+ end
39
+
40
+ def normalize_hash(hash)
41
+ return {} if hash.nil?
42
+ raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
43
+
44
+ hash.each_with_object({}) do |(key, value), normalized|
45
+ normalized[key.to_s] = value
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -12,7 +12,11 @@ module LlmCostTracker
12
12
  def call(path:, registry:)
13
13
  FileUtils.mkdir_p(File.dirname(path))
14
14
  payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
15
- File.write(path, payload)
15
+ temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
16
+ File.write(temp_path, payload)
17
+ File.rename(temp_path, path)
18
+ ensure
19
+ FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
16
20
  end
17
21
 
18
22
  private
@@ -1,141 +1,143 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require "json"
5
+ require "rubygems"
4
6
 
5
7
  require_relative "price_sync/fetcher"
6
- require_relative "price_sync/raw_price"
7
- require_relative "price_sync/source"
8
- require_relative "price_sync/source_result"
8
+ require_relative "price_sync/registry_diff"
9
9
  require_relative "price_sync/registry_loader"
10
10
  require_relative "price_sync/registry_writer"
11
- require_relative "price_sync/refresh_plan_builder"
12
- require_relative "price_sync/model_catalog"
13
- require_relative "price_sync/merger"
14
- require_relative "price_sync/validator"
15
- require_relative "price_sync/sources/litellm"
16
- require_relative "price_sync/sources/open_router"
17
11
 
18
12
  module LlmCostTracker
19
13
  module PriceSync
20
- DEFAULT_OUTPUT_PATH = PriceRegistry::DEFAULT_PRICES_PATH
21
-
22
- SourceUsage = Data.define(:prices_count, :source_version)
23
- SyncResult = Data.define(
24
- :path,
25
- :updated_models,
26
- :changes,
27
- :orphaned_models,
28
- :failed_sources,
29
- :discrepancies,
30
- :rejected,
31
- :flagged,
32
- :sources_used,
33
- :written
34
- )
35
- CheckResult = Data.define(
36
- :path,
37
- :changes,
38
- :orphaned_models,
39
- :failed_sources,
40
- :discrepancies,
41
- :rejected,
42
- :flagged,
43
- :sources_used,
44
- :up_to_date
45
- )
46
- RefreshPlan = Data.define(
47
- :path,
48
- :registry,
49
- :updated_registry,
50
- :accepted,
51
- :changes,
52
- :orphaned_models,
53
- :failed_sources,
54
- :discrepancies,
55
- :rejected,
56
- :flagged,
57
- :sources_used,
58
- :source_results
59
- ) do
60
- def refresh_succeeded?
61
- source_results.any? { |_source, result| result.prices.any? }
14
+ DEFAULT_OUTPUT_PATH = "config/llm_cost_tracker_prices.yml"
15
+ DEFAULT_REMOTE_URL =
16
+ "https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
17
+ SUPPORTED_SCHEMA_VERSION = 1
18
+
19
+ RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
20
+ CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
21
+
22
+ class << self
23
+ def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
24
+ output = env["OUTPUT"].to_s.strip
25
+ return output unless output.empty?
26
+
27
+ prices_file = config.prices_file
28
+ return prices_file.to_s if prices_file
29
+
30
+ default_output_path
62
31
  end
63
32
 
64
- def up_to_date?
65
- changes.empty? && failed_sources.empty? && rejected.empty?
33
+ def configured_remote_url(env: ENV)
34
+ url = env["URL"].to_s.strip
35
+ url.empty? ? DEFAULT_REMOTE_URL : url
66
36
  end
67
- end
68
37
 
69
- class << self
70
- def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
71
- fetcher: Fetcher.new, today: Date.today)
72
- plan = RefreshPlanBuilder.new(sources: sources).call(
73
- path: path,
74
- seed_path: seed_path,
75
- fetcher: fetcher,
76
- today: today
77
- )
78
- raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
79
-
80
- written = !preview && plan.refresh_succeeded?
81
- RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
82
-
83
- SyncResult.new(
84
- path: plan.path,
85
- updated_models: plan.changes.keys.sort,
86
- changes: plan.changes,
87
- orphaned_models: plan.orphaned_models,
88
- failed_sources: plan.failed_sources,
89
- discrepancies: plan.discrepancies,
90
- rejected: plan.rejected,
91
- flagged: plan.flagged,
92
- sources_used: plan.sources_used,
93
- written: written
94
- )
38
+ def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
39
+ today: Date.today)
40
+ current = load_current_registry(path)
41
+ response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
42
+
43
+ if response.not_modified
44
+ return refresh_result(path, url, response, current, current, written: false, not_modified: true)
45
+ end
46
+
47
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
48
+ RegistryWriter.new.call(path: path, registry: remote) unless preview
49
+ refresh_result(path, url, response, current, remote, written: !preview, not_modified: false)
95
50
  end
96
51
 
97
- def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
98
- plan = RefreshPlanBuilder.new(sources: sources).call(
99
- path: path,
100
- seed_path: seed_path,
101
- fetcher: fetcher,
102
- today: today
103
- )
52
+ def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
53
+ current = load_current_registry(path)
54
+ response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
55
+
56
+ if response.not_modified
57
+ return CheckResult.new(
58
+ path: path,
59
+ source_url: url,
60
+ source_version: response.source_version,
61
+ changes: {},
62
+ up_to_date: true
63
+ )
64
+ end
65
+
66
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
67
+ changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
104
68
 
105
69
  CheckResult.new(
106
- path: plan.path,
107
- changes: plan.changes,
108
- orphaned_models: plan.orphaned_models,
109
- failed_sources: plan.failed_sources,
110
- discrepancies: plan.discrepancies,
111
- rejected: plan.rejected,
112
- flagged: plan.flagged,
113
- sources_used: plan.sources_used,
114
- up_to_date: plan.up_to_date?
70
+ path: path,
71
+ source_url: url,
72
+ source_version: response.source_version,
73
+ changes: changes,
74
+ up_to_date: changes.empty?
115
75
  )
116
76
  end
117
77
 
118
78
  private
119
79
 
120
- def sources
121
- [Sources::Litellm.new, Sources::OpenRouter.new]
80
+ def default_output_path
81
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
82
+ Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
83
+ else
84
+ DEFAULT_OUTPUT_PATH
85
+ end
122
86
  end
123
87
 
124
- def strict_sync_failure?(plan, strict:)
125
- strict && (plan.failed_sources.any? || plan.rejected.any?)
88
+ def load_current_registry(path)
89
+ RegistryLoader.new.call(path: path, seed_path: PriceRegistry::DEFAULT_PRICES_PATH)
126
90
  end
127
91
 
128
- def strict_failure_message(plan)
129
- messages = []
130
- if plan.failed_sources.any?
131
- details = plan.failed_sources.map { |source, message| "#{source}: #{message}" }.join(", ")
132
- messages << "source failures: #{details}"
92
+ def normalize_remote_registry(body, url:, response:, today:)
93
+ registry = parse_registry(body)
94
+ metadata = registry.fetch("metadata", {})
95
+ raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
96
+
97
+ schema_version = Integer(metadata.fetch("schema_version", 1))
98
+ if schema_version > SUPPORTED_SCHEMA_VERSION
99
+ raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
133
100
  end
134
- if plan.rejected.any?
135
- details = plan.rejected.map { |issue| "#{issue.model} (#{issue.reason})" }.join(", ")
136
- messages << "validator rejections: #{details}"
101
+
102
+ min_gem_version = metadata["min_gem_version"]
103
+ if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
104
+ raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
137
105
  end
138
- "Price sync failed in strict mode: #{messages.join('; ')}"
106
+
107
+ models = registry.fetch("models", {})
108
+ PriceRegistry.normalize_price_table(models)
109
+
110
+ registry.merge(
111
+ "metadata" => metadata.merge(
112
+ "schema_version" => schema_version,
113
+ "updated_at" => metadata["updated_at"] || today.iso8601,
114
+ "source_url" => url,
115
+ "source_version" => response.source_version
116
+ ),
117
+ "models" => models
118
+ )
119
+ rescue ArgumentError, TypeError => e
120
+ raise Error, "Unable to load remote pricing snapshot: #{e.message}"
121
+ end
122
+
123
+ def parse_registry(body)
124
+ registry = JSON.parse(body.to_s)
125
+ raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
126
+
127
+ registry
128
+ rescue JSON::ParserError => e
129
+ raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
130
+ end
131
+
132
+ def refresh_result(path, url, response, current, remote, written:, not_modified:)
133
+ RefreshResult.new(
134
+ path: path,
135
+ source_url: url,
136
+ source_version: response.source_version,
137
+ changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
138
+ written: written,
139
+ not_modified: not_modified
140
+ )
139
141
  end
140
142
  end
141
143
  end