llm_cost_tracker 0.1.1 → 0.1.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +202 -11
  4. data/lib/llm_cost_tracker/budget.rb +97 -0
  5. data/lib/llm_cost_tracker/configuration.rb +37 -0
  6. data/lib/llm_cost_tracker/errors.rb +37 -0
  7. data/lib/llm_cost_tracker/event_metadata.rb +54 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +29 -0
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +9 -0
  10. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +16 -4
  11. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -1
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +15 -0
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +41 -0
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +29 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +29 -0
  16. data/lib/llm_cost_tracker/llm_api_call.rb +68 -2
  17. data/lib/llm_cost_tracker/middleware/faraday.rb +50 -12
  18. data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -1
  19. data/lib/llm_cost_tracker/parsers/gemini.rb +9 -2
  20. data/lib/llm_cost_tracker/parsers/openai.rb +10 -3
  21. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +44 -0
  22. data/lib/llm_cost_tracker/parsers/registry.rb +16 -7
  23. data/lib/llm_cost_tracker/price_registry.rb +69 -0
  24. data/lib/llm_cost_tracker/prices.json +51 -0
  25. data/lib/llm_cost_tracker/pricing.rb +74 -74
  26. data/lib/llm_cost_tracker/railtie.rb +3 -0
  27. data/lib/llm_cost_tracker/storage/active_record_store.rb +12 -3
  28. data/lib/llm_cost_tracker/tracker.rb +49 -54
  29. data/lib/llm_cost_tracker/unknown_pricing.rb +47 -0
  30. data/lib/llm_cost_tracker/version.rb +1 -1
  31. data/lib/llm_cost_tracker.rb +33 -5
  32. data/llm_cost_tracker.gemspec +4 -3
  33. metadata +20 -6
@@ -6,10 +6,15 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
6
6
  t.integer :input_tokens, null: false, default: 0
7
7
  t.integer :output_tokens, null: false, default: 0
8
8
  t.integer :total_tokens, null: false, default: 0
9
- t.decimal :input_cost, precision: 12, scale: 8
10
- t.decimal :output_cost, precision: 12, scale: 8
11
- t.decimal :total_cost, precision: 12, scale: 8
12
- t.text :tags
9
+ t.decimal :input_cost, precision: 20, scale: 8
10
+ t.decimal :output_cost, precision: 20, scale: 8
11
+ t.decimal :total_cost, precision: 20, scale: 8
12
+ t.integer :latency_ms
13
+ if postgresql?
14
+ t.jsonb :tags, null: false, default: {}
15
+ else
16
+ t.text :tags
17
+ end
13
18
  t.datetime :tracked_at, null: false
14
19
 
15
20
  t.timestamps
@@ -19,5 +24,12 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
19
24
  add_index :llm_api_calls, :model
20
25
  add_index :llm_api_calls, :tracked_at
21
26
  add_index :llm_api_calls, [:provider, :tracked_at]
27
+ add_index :llm_api_calls, :tags, using: :gin if postgresql?
28
+ end
29
+
30
+ private
31
+
32
+ def postgresql?
33
+ connection.adapter_name.downcase.include?("postgres")
22
34
  end
23
35
  end
@@ -12,6 +12,13 @@ LlmCostTracker.configure do |config|
12
12
 
13
13
  # Monthly budget in USD. Set to nil to disable budget alerts.
14
14
  # config.monthly_budget = 100.00
15
+ # config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
16
+
17
+ # What to do when storage fails.
18
+ # config.storage_error_behavior = :warn # :ignore, :warn, or :raise
19
+
20
+ # What to do when a model has no built-in price and no pricing_overrides entry.
21
+ # config.unknown_pricing_behavior = :warn # :ignore, :warn, or :raise
15
22
 
16
23
  # Callback when monthly budget is exceeded.
17
24
  # config.on_budget_exceeded = ->(data) {
@@ -20,8 +27,14 @@ LlmCostTracker.configure do |config|
20
27
  # # Or send a Slack notification, email, etc.
21
28
  # }
22
29
 
23
- # Override built-in pricing for specific models (per 1M tokens, USD)
30
+ # Load a local JSON/YAML price table that overrides built-in pricing.
31
+ # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.json")
32
+
33
+ # Override pricing for specific models in Ruby (per 1M tokens, USD).
24
34
  # config.pricing_overrides = {
25
35
  # "my-custom-model" => { input: 1.00, output: 2.00 }
26
36
  # }
37
+
38
+ # OpenAI-compatible APIs. OpenRouter and DeepSeek are included by default.
39
+ # config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
27
40
  end
@@ -0,0 +1,15 @@
1
+ class UpgradeLlmApiCallCostPrecision < ActiveRecord::Migration<%= migration_version %>
2
+ COST_COLUMNS = %i[input_cost output_cost total_cost].freeze
3
+
4
+ def up
5
+ COST_COLUMNS.each do |column|
6
+ change_column :llm_api_calls, column, :decimal, precision: 20, scale: 8
7
+ end
8
+ end
9
+
10
+ def down
11
+ COST_COLUMNS.each do |column|
12
+ change_column :llm_api_calls, column, :decimal, precision: 12, scale: 8
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,41 @@
1
+ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ unless postgresql?
4
+ say "Skipping llm_api_calls.tags JSONB upgrade: database adapter is #{connection.adapter_name}."
5
+ return
6
+ end
7
+
8
+ return if tags_jsonb?
9
+
10
+ remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
11
+
12
+ change_column(
13
+ :llm_api_calls,
14
+ :tags,
15
+ :jsonb,
16
+ using: "CASE WHEN tags IS NULL OR tags = '' THEN '{}'::jsonb ELSE tags::jsonb END",
17
+ default: {},
18
+ null: false
19
+ )
20
+
21
+ add_index :llm_api_calls, :tags, using: :gin unless index_exists?(:llm_api_calls, :tags)
22
+ end
23
+
24
+ def down
25
+ return unless postgresql?
26
+
27
+ remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
28
+ change_column :llm_api_calls, :tags, :text, using: "tags::text"
29
+ end
30
+
31
+ private
32
+
33
+ def postgresql?
34
+ connection.adapter_name.downcase.include?("postgres")
35
+ end
36
+
37
+ def tags_jsonb?
38
+ column = connection.columns(:llm_api_calls).find { |candidate| candidate.name == "tags" }
39
+ column&.sql_type.to_s.downcase == "jsonb"
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeCostPrecisionGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to widen llm_api_calls cost decimal precision"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_llm_api_call_cost_precision.rb.erb",
18
+ "db/migrate/upgrade_llm_api_call_cost_precision.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeTagsToJsonbGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to upgrade llm_api_calls.tags to PostgreSQL JSONB"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_llm_api_call_tags_to_jsonb.rb.erb",
18
+ "db/migrate/upgrade_llm_api_call_tags_to_jsonb.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record"
4
+ require "json"
4
5
 
5
6
  module LlmCostTracker
6
7
  class LlmApiCall < ActiveRecord::Base
@@ -9,8 +10,33 @@ module LlmCostTracker
9
10
  # Scopes for querying
10
11
  scope :by_provider, ->(provider) { where(provider: provider) }
11
12
  scope :by_model, ->(model) { where(model: model) }
12
- scope :by_tag, lambda { |key, value|
13
- where("tags LIKE ?", "%\"#{key}\":\"#{value}\"%")
13
+ scope :by_tag, ->(key, value) { by_tags(key => value) }
14
+ scope :by_tags, lambda { |tags|
15
+ normalized_tags = normalize_tags(tags)
16
+
17
+ if normalized_tags.empty?
18
+ all
19
+ elsif tags_json_column?
20
+ where("tags @> ?::jsonb", normalized_tags.to_json)
21
+ else
22
+ normalized_tags.reduce(all) do |relation, (key, value)|
23
+ relation.where("tags LIKE ? ESCAPE '\\'", "%#{sanitize_sql_like(json_tag_fragment(key, value))}%")
24
+ end
25
+ end
26
+ }
27
+ scope :by_user, ->(user_id) { by_tag("user_id", user_id) }
28
+ scope :by_feature, ->(feature) { by_tag("feature", feature) }
29
+ scope :with_cost, -> { where.not(total_cost: nil) }
30
+ scope :without_cost, -> { where(total_cost: nil) }
31
+ scope :unknown_pricing, -> { without_cost }
32
+ scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
33
+
34
+ scope :with_json_tags, lambda {
35
+ if tags_json_column?
36
+ where.not(tags: {})
37
+ else
38
+ where.not(tags: [nil, "", "{}"])
39
+ end
14
40
  }
15
41
 
16
42
  scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
@@ -35,13 +61,53 @@ module LlmCostTracker
35
61
  group(:provider).sum(:total_cost)
36
62
  end
37
63
 
64
+ def self.average_latency_ms
65
+ return nil unless latency_column?
66
+
67
+ average(:latency_ms)&.to_f
68
+ end
69
+
70
+ def self.latency_by_model
71
+ return {} unless latency_column?
72
+
73
+ group(:model).average(:latency_ms).transform_values(&:to_f)
74
+ end
75
+
76
+ def self.latency_by_provider
77
+ return {} unless latency_column?
78
+
79
+ group(:provider).average(:latency_ms).transform_values(&:to_f)
80
+ end
81
+
38
82
  def self.daily_costs(days: 30)
39
83
  where(tracked_at: days.days.ago..)
40
84
  .group("DATE(tracked_at)")
41
85
  .sum(:total_cost)
86
+ .transform_keys(&:to_s)
87
+ end
88
+
89
+ def self.tags_json_column?
90
+ column = columns_hash["tags"]
91
+ return false unless column
92
+
93
+ %i[json jsonb].include?(column.type) || column.sql_type.to_s.downcase == "jsonb"
94
+ end
95
+
96
+ def self.latency_column?
97
+ columns_hash.key?("latency_ms")
98
+ end
99
+
100
+ def self.normalize_tags(tags)
101
+ (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
102
+ end
103
+
104
+ def self.json_tag_fragment(key, value)
105
+ JSON.generate(key => value).delete_prefix("{").delete_suffix("}")
42
106
  end
43
107
 
44
108
  def parsed_tags
109
+ return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
110
+
45
111
  JSON.parse(tags || "{}")
46
112
  rescue JSON::ParserError
47
113
  {}
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require "json"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Middleware
@@ -14,25 +15,23 @@ module LlmCostTracker
14
15
  return @app.call(request_env) unless LlmCostTracker.configuration.enabled
15
16
 
16
17
  request_url = request_env.url.to_s
17
- request_body = read_body(request_env.body)
18
+ request_body = read_body(request_env.body) || ""
19
+
20
+ enforce_budget_before_request(request_url)
21
+ started_at = monotonic_time
18
22
 
19
23
  @app.call(request_env).on_complete do |response_env|
20
- process(request_url, request_body, response_env)
24
+ process(request_url, request_body, response_env, elapsed_ms(started_at))
21
25
  end
22
26
  end
23
27
 
24
28
  private
25
29
 
26
- def process(request_url, request_body, response_env)
30
+ def process(request_url, request_body, response_env, latency_ms)
27
31
  parser = Parsers::Registry.find_for(request_url)
28
32
  return unless parser
29
33
 
30
- parsed = parser.parse(
31
- request_url,
32
- request_body,
33
- response_env.status,
34
- read_body(response_env.body)
35
- )
34
+ parsed = parse_response(parser, request_url, request_body, response_env)
36
35
  return unless parsed
37
36
 
38
37
  Tracker.record(
@@ -40,21 +39,60 @@ module LlmCostTracker
40
39
  model: parsed[:model],
41
40
  input_tokens: parsed[:input_tokens],
42
41
  output_tokens: parsed[:output_tokens],
42
+ latency_ms: latency_ms,
43
43
  metadata: @tags.merge(parsed.except(:provider, :model, :input_tokens, :output_tokens, :total_tokens))
44
44
  )
45
+ rescue LlmCostTracker::Error
46
+ raise
45
47
  rescue StandardError => e
46
- return unless LlmCostTracker.configuration.log_level == :debug
48
+ log_warning("Error processing response: #{e.class}: #{e.message}")
49
+ end
50
+
51
+ def parse_response(parser, request_url, request_body, response_env)
52
+ response_body = read_body(response_env.body)
53
+ unless response_body
54
+ log_warning(
55
+ "Unable to read response body for #{request_url}; streaming/SSE responses require manual tracking."
56
+ )
57
+ return nil
58
+ end
59
+
60
+ parser.parse(request_url, request_body, response_env.status, response_body)
61
+ end
62
+
63
+ def enforce_budget_before_request(request_url)
64
+ return unless Parsers::Registry.find_for(request_url)
47
65
 
48
- warn "[LlmCostTracker] Error processing response: #{e.message}"
66
+ Tracker.enforce_budget!
49
67
  end
50
68
 
51
69
  def read_body(body)
52
70
  case body
53
71
  when String then body
54
72
  when nil then ""
55
- else body.to_s
73
+ when Hash, Array then body.to_json
74
+ else
75
+ body.respond_to?(:to_str) ? body.to_str : nil
56
76
  end
57
77
  end
78
+
79
+ def log_warning(message)
80
+ message = "[LlmCostTracker] #{message}"
81
+
82
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
83
+ Rails.logger.warn(message)
84
+ else
85
+ warn message
86
+ end
87
+ end
88
+
89
+ def monotonic_time
90
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ end
92
+
93
+ def elapsed_ms(started_at)
94
+ ((monotonic_time - started_at) * 1000).round
95
+ end
58
96
  end
59
97
  end
60
98
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
5
+
6
+ require_relative "base"
4
7
 
5
8
  module LlmCostTracker
6
9
  module Parsers
@@ -9,7 +12,7 @@ module LlmCostTracker
9
12
 
10
13
  def match?(url)
11
14
  uri = URI.parse(url.to_s)
12
- HOSTS.include?(uri.host) && uri.path.include?("/v1/messages")
15
+ HOSTS.include?(uri.host.to_s.downcase) && uri.path.include?("/v1/messages")
13
16
  rescue URI::InvalidURIError
14
17
  false
15
18
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
5
+
6
+ require_relative "base"
4
7
 
5
8
  module LlmCostTracker
6
9
  module Parsers
@@ -9,7 +12,7 @@ module LlmCostTracker
9
12
 
10
13
  def match?(url)
11
14
  uri = URI.parse(url.to_s)
12
- HOSTS.include?(uri.host)
15
+ HOSTS.include?(uri.host.to_s.downcase)
13
16
  rescue URI::InvalidURIError
14
17
  false
15
18
  end
@@ -28,7 +31,7 @@ module LlmCostTracker
28
31
  provider: "gemini",
29
32
  model: model,
30
33
  input_tokens: usage["promptTokenCount"] || 0,
31
- output_tokens: usage["candidatesTokenCount"] || 0,
34
+ output_tokens: output_tokens(usage),
32
35
  total_tokens: usage["totalTokenCount"] || 0,
33
36
  cached_input_tokens: usage["cachedContentTokenCount"]
34
37
  }.compact
@@ -36,6 +39,10 @@ module LlmCostTracker
36
39
 
37
40
  private
38
41
 
42
+ def output_tokens(usage)
43
+ (usage["candidatesTokenCount"] || 0) + (usage["thoughtsTokenCount"] || 0)
44
+ end
45
+
39
46
  def extract_model_from_url(url)
40
47
  uri = URI.parse(url.to_s)
41
48
  match = uri.path.match(%r{/models/([^/:]+)})
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
5
+
6
+ require_relative "base"
4
7
 
5
8
  module LlmCostTracker
6
9
  module Parsers
@@ -10,12 +13,12 @@ module LlmCostTracker
10
13
 
11
14
  def match?(url)
12
15
  uri = URI.parse(url.to_s)
13
- HOSTS.include?(uri.host) && TRACKED_PATHS.include?(uri.path)
16
+ HOSTS.include?(uri.host.to_s.downcase) && TRACKED_PATHS.include?(uri.path)
14
17
  rescue URI::InvalidURIError
15
18
  false
16
19
  end
17
20
 
18
- def parse(_request_url, request_body, response_status, response_body)
21
+ def parse(request_url, request_body, response_status, response_body)
19
22
  return nil unless response_status == 200
20
23
 
21
24
  response = safe_json_parse(response_body)
@@ -25,7 +28,7 @@ module LlmCostTracker
25
28
  request = safe_json_parse(request_body)
26
29
 
27
30
  {
28
- provider: "openai",
31
+ provider: provider_for(request_url),
29
32
  model: response["model"] || request["model"],
30
33
  input_tokens: usage["prompt_tokens"] || usage["input_tokens"] || 0,
31
34
  output_tokens: usage["completion_tokens"] || usage["output_tokens"] || 0,
@@ -36,6 +39,10 @@ module LlmCostTracker
36
39
 
37
40
  private
38
41
 
42
+ def provider_for(_request_url)
43
+ "openai"
44
+ end
45
+
39
46
  def cached_input_tokens(usage)
40
47
  details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
41
48
  details["cached_tokens"]
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai"
4
+
5
+ module LlmCostTracker
6
+ module Parsers
7
+ class OpenaiCompatible < Openai
8
+ TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
9
+
10
+ def match?(url)
11
+ uri = URI.parse(url.to_s)
12
+ !provider_for_host(uri.host).nil? && tracked_path?(uri.path)
13
+ rescue URI::InvalidURIError
14
+ false
15
+ end
16
+
17
+ private
18
+
19
+ def provider_for(request_url)
20
+ uri = URI.parse(request_url.to_s)
21
+ provider_for_host(uri.host) || "openai_compatible"
22
+ rescue URI::InvalidURIError
23
+ "openai_compatible"
24
+ end
25
+
26
+ def provider_for_host(host)
27
+ host = host.to_s.downcase
28
+ provider_name = configured_providers[host] ||
29
+ configured_providers.find do |configured_host, _provider|
30
+ configured_host.to_s.downcase == host
31
+ end&.last
32
+ provider_name&.to_s
33
+ end
34
+
35
+ def configured_providers
36
+ LlmCostTracker.configuration.openai_compatible_providers
37
+ end
38
+
39
+ def tracked_path?(path)
40
+ TRACKED_PATH_SUFFIXES.any? { |suffix| path == suffix || path.end_with?(suffix) }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -4,16 +4,14 @@ module LlmCostTracker
4
4
  module Parsers
5
5
  class Registry
6
6
  class << self
7
+ PARSERS_MUTEX = Mutex.new
8
+
7
9
  def parsers
8
- @parsers ||= [
9
- Openai.new,
10
- Anthropic.new,
11
- Gemini.new
12
- ]
10
+ @parsers || PARSERS_MUTEX.synchronize { @parsers ||= default_parsers }
13
11
  end
14
12
 
15
13
  def register(parser)
16
- parsers.unshift(parser)
14
+ PARSERS_MUTEX.synchronize { parsers.unshift(parser) }
17
15
  end
18
16
 
19
17
  def find_for(url)
@@ -21,7 +19,18 @@ module LlmCostTracker
21
19
  end
22
20
 
23
21
  def reset!
24
- @parsers = nil
22
+ PARSERS_MUTEX.synchronize { @parsers = nil }
23
+ end
24
+
25
+ private
26
+
27
+ def default_parsers
28
+ [
29
+ Openai.new,
30
+ OpenaiCompatible.new,
31
+ Anthropic.new,
32
+ Gemini.new
33
+ ]
25
34
  end
26
35
  end
27
36
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module LlmCostTracker
7
+ module PriceRegistry
8
+ DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
9
+ PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
10
+ NORMALIZE_PRICE_ENTRY = lambda do |price|
11
+ (price || {}).each_with_object({}) do |(key, value), normalized|
12
+ key = key.to_s
13
+ normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
14
+ end
15
+ end
16
+ NORMALIZE_PRICE_TABLE = lambda do |table|
17
+ (table || {}).each_with_object({}) do |(model, price), normalized|
18
+ normalized[model.to_s] = NORMALIZE_PRICE_ENTRY.call(price)
19
+ end
20
+ end
21
+ RAW_REGISTRY = JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
22
+ PRICE_METADATA = RAW_REGISTRY.fetch("metadata", {}).freeze
23
+ BUILTIN_PRICES = NORMALIZE_PRICE_TABLE.call(RAW_REGISTRY.fetch("models", {})).freeze
24
+
25
+ class << self
26
+ def builtin_prices
27
+ BUILTIN_PRICES
28
+ end
29
+
30
+ def metadata
31
+ PRICE_METADATA
32
+ end
33
+
34
+ def normalize_price_table(table)
35
+ NORMALIZE_PRICE_TABLE.call(table)
36
+ end
37
+
38
+ def file_prices(path)
39
+ return {} unless path
40
+
41
+ path = path.to_s
42
+ cache_key = [path, File.mtime(path).to_f]
43
+ return @file_prices if @file_prices_cache_key == cache_key
44
+
45
+ @file_prices_cache_key = cache_key
46
+ @file_prices = normalize_price_table(price_file_models(load_price_file(path)))
47
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
48
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
49
+ end
50
+
51
+ private
52
+
53
+ def load_price_file(path)
54
+ contents = File.read(path)
55
+ return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
56
+
57
+ JSON.parse(contents)
58
+ end
59
+
60
+ def yaml_file?(path)
61
+ %w[.yaml .yml].include?(File.extname(path).downcase)
62
+ end
63
+
64
+ def price_file_models(registry)
65
+ registry.fetch("models", registry)
66
+ end
67
+ end
68
+ end
69
+ end