llm_cost_tracker 0.5.0 → 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.
@@ -160,7 +160,7 @@ module LlmCostTracker
160
160
  def feature_generators(columns) = columns.map { |column| FEATURE_COLUMNS.fetch(column) }.uniq
161
161
 
162
162
  def builtin_prices_updated_at
163
- LlmCostTracker::Pricing.metadata.fetch("updated_at", "unknown")
163
+ LlmCostTracker::PriceRegistry.metadata.fetch("updated_at", "unknown")
164
164
  end
165
165
  end
166
166
  end
@@ -33,7 +33,7 @@ LlmCostTracker.configure do |config|
33
33
  <% if options[:prices] -%>
34
34
 
35
35
  # Local JSON/YAML pricing file generated by --prices. Keep it in source control
36
- # and refresh it with bin/rails llm_cost_tracker:prices:sync.
36
+ # and refresh it with bin/rails llm_cost_tracker:prices:refresh.
37
37
  config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
38
38
  <% end -%>
39
39
 
@@ -78,8 +78,8 @@ module LlmCostTracker
78
78
  unless response_body
79
79
  Logging.warn(
80
80
  "Unable to read response body for #{RequestUrl.label(request_url)}; " \
81
- "streaming responses are captured automatically for OpenAI/Anthropic/Gemini " \
82
- "or via LlmCostTracker.track_stream for custom clients."
81
+ "known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
82
+ "for custom clients."
83
83
  )
84
84
  return nil
85
85
  end
@@ -17,20 +17,20 @@ module LlmCostTracker
17
17
 
18
18
  [:ok, "updated_at=#{updated_at}"]
19
19
  rescue Date::Error
20
- [:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:sync"]
20
+ [:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:refresh"]
21
21
  end
22
22
 
23
23
  private
24
24
 
25
25
  def missing
26
- [:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:sync"]
26
+ [:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:refresh"]
27
27
  end
28
28
 
29
29
  def stale(updated_at)
30
30
  [
31
31
  :warn,
32
32
  "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
33
- "run bin/rails llm_cost_tracker:prices:sync"
33
+ "run bin/rails llm_cost_tracker:prices:refresh"
34
34
  ]
35
35
  end
36
36
  end
@@ -15,7 +15,7 @@ module LlmCostTracker
15
15
  end
16
16
  end
17
17
 
18
- USER_AGENT = "llm_cost_tracker price sync"
18
+ USER_AGENT = "llm_cost_tracker price refresh"
19
19
  MAX_REDIRECTS = 5
20
20
  OPEN_TIMEOUT = 5
21
21
  READ_TIMEOUT = 10
@@ -25,6 +25,8 @@ module LlmCostTracker
25
25
  raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
26
26
 
27
27
  uri = URI.parse(url)
28
+ raise Error, "Pricing snapshot URL must use http or https" unless %w[http https].include?(uri.scheme)
29
+
28
30
  request = Net::HTTP::Get.new(uri)
29
31
  request["User-Agent"] = USER_AGENT
30
32
  request["If-None-Match"] = etag if etag
@@ -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,70 +1,23 @@
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? }
62
- end
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
63
18
 
64
- def up_to_date?
65
- changes.empty? && failed_sources.empty? && rejected.empty?
66
- end
67
- end
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)
68
21
 
69
22
  class << self
70
23
  def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
@@ -74,78 +27,117 @@ module LlmCostTracker
74
27
  prices_file = config.prices_file
75
28
  return prices_file.to_s if prices_file
76
29
 
77
- nil
30
+ default_output_path
78
31
  end
79
32
 
80
- def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
81
- fetcher: Fetcher.new, today: Date.today)
82
- plan = RefreshPlanBuilder.new(sources: sources).call(
83
- path: path,
84
- seed_path: seed_path,
85
- fetcher: fetcher,
86
- today: today
87
- )
88
- raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
89
-
90
- written = !preview && plan.refresh_succeeded?
91
- RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
92
-
93
- SyncResult.new(
94
- path: plan.path,
95
- updated_models: plan.changes.keys.sort,
96
- changes: plan.changes,
97
- orphaned_models: plan.orphaned_models,
98
- failed_sources: plan.failed_sources,
99
- discrepancies: plan.discrepancies,
100
- rejected: plan.rejected,
101
- flagged: plan.flagged,
102
- sources_used: plan.sources_used,
103
- written: written
104
- )
33
+ def configured_remote_url(env: ENV)
34
+ url = env["URL"].to_s.strip
35
+ url.empty? ? DEFAULT_REMOTE_URL : url
105
36
  end
106
37
 
107
- def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
108
- plan = RefreshPlanBuilder.new(sources: sources).call(
109
- path: path,
110
- seed_path: seed_path,
111
- fetcher: fetcher,
112
- today: today
113
- )
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)
50
+ end
51
+
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", {}))
114
68
 
115
69
  CheckResult.new(
116
- path: plan.path,
117
- changes: plan.changes,
118
- orphaned_models: plan.orphaned_models,
119
- failed_sources: plan.failed_sources,
120
- discrepancies: plan.discrepancies,
121
- rejected: plan.rejected,
122
- flagged: plan.flagged,
123
- sources_used: plan.sources_used,
124
- 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?
125
75
  )
126
76
  end
127
77
 
128
78
  private
129
79
 
130
- def sources
131
- [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
132
86
  end
133
87
 
134
- def strict_sync_failure?(plan, strict:)
135
- 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)
136
90
  end
137
91
 
138
- def strict_failure_message(plan)
139
- messages = []
140
- if plan.failed_sources.any?
141
- details = plan.failed_sources.map { |source, message| "#{source}: #{message}" }.join(", ")
142
- 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"
143
100
  end
144
- if plan.rejected.any?
145
- details = plan.rejected.map { |issue| "#{issue.model} (#{issue.reason})" }.join(", ")
146
- 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}"
147
105
  end
148
- "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
+ )
149
141
  end
150
142
  end
151
143
  end