llm_cost_tracker 0.2.0 → 0.3.0

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +111 -68
  4. data/Rakefile +2 -0
  5. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
  6. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
  7. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  8. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -0
  9. data/app/views/llm_cost_tracker/calls/index.html.erb +10 -0
  10. data/app/views/llm_cost_tracker/dashboard/index.html.erb +10 -0
  11. data/app/views/llm_cost_tracker/data_quality/index.html.erb +46 -0
  12. data/lib/llm_cost_tracker/assets.rb +6 -11
  13. data/lib/llm_cost_tracker/configuration.rb +78 -42
  14. data/lib/llm_cost_tracker/event.rb +2 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  19. data/lib/llm_cost_tracker/llm_api_call.rb +8 -0
  20. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  21. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  22. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  23. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  24. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  25. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  26. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  27. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  28. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  29. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  30. data/lib/llm_cost_tracker/price_registry.rb +1 -1
  31. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  32. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  33. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  34. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  35. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  36. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  37. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  38. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  39. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  40. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  41. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  42. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  43. data/lib/llm_cost_tracker/tags_column.rb +8 -0
  44. data/lib/llm_cost_tracker/tracker.rb +15 -12
  45. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  46. data/lib/llm_cost_tracker/version.rb +1 -1
  47. data/lib/llm_cost_tracker.rb +50 -29
  48. data/lib/tasks/llm_cost_tracker.rake +116 -0
  49. data/llm_cost_tracker.gemspec +8 -6
  50. metadata +24 -8
@@ -19,10 +19,18 @@ module LlmCostTracker
19
19
  false
20
20
  end
21
21
 
22
+ def provider_names
23
+ ["openai_compatible", *configured_providers.each_value.map(&:to_s)].uniq.freeze
24
+ end
25
+
22
26
  def parse(request_url, request_body, response_status, response_body)
23
27
  parse_openai_usage(request_url, request_body, response_status, response_body)
24
28
  end
25
29
 
30
+ def parse_stream(request_url, request_body, response_status, events)
31
+ parse_openai_stream_usage(request_url, request_body, response_status, events)
32
+ end
33
+
26
34
  private
27
35
 
28
36
  def provider_for(request_url)
@@ -20,10 +20,64 @@ module LlmCostTracker
20
20
  input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
21
21
  output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
22
22
  total_tokens: usage["total_tokens"].to_i,
23
- cached_input_tokens: cached_input_tokens(usage)
23
+ cached_input_tokens: cached_input_tokens(usage),
24
+ usage_source: :response
24
25
  )
25
26
  end
26
27
 
28
+ def parse_openai_stream_usage(request_url, request_body, response_status, events)
29
+ return nil unless response_status == 200
30
+
31
+ request = safe_json_parse(request_body)
32
+ model = detect_stream_model(events) || request["model"]
33
+ usage = detect_stream_usage(events)
34
+
35
+ if usage
36
+ ParsedUsage.build(
37
+ provider: provider_for(request_url),
38
+ model: model,
39
+ input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
40
+ output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
41
+ total_tokens: usage["total_tokens"].to_i,
42
+ cached_input_tokens: cached_input_tokens(usage),
43
+ stream: true,
44
+ usage_source: :stream_final
45
+ )
46
+ else
47
+ ParsedUsage.build(
48
+ provider: provider_for(request_url),
49
+ model: model,
50
+ input_tokens: 0,
51
+ output_tokens: 0,
52
+ total_tokens: 0,
53
+ stream: true,
54
+ usage_source: :unknown
55
+ )
56
+ end
57
+ end
58
+
59
+ def detect_stream_usage(events)
60
+ events.reverse_each do |event|
61
+ data = event[:data]
62
+ next unless data.is_a?(Hash)
63
+
64
+ usage = data["usage"]
65
+ return usage if usage.is_a?(Hash) && !usage.empty?
66
+ end
67
+ nil
68
+ end
69
+
70
+ def detect_stream_model(events)
71
+ events.each do |event|
72
+ data = event[:data]
73
+ next unless data.is_a?(Hash)
74
+
75
+ model = data["model"]
76
+ return model if model && !model.to_s.empty?
77
+ end
78
+ nil
79
+ end
80
+
27
81
  def cached_input_tokens(usage)
28
82
  details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
29
83
  details["cached_tokens"]
@@ -1,23 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module LlmCostTracker
4
6
  module Parsers
5
7
  class Registry
8
+ MUTEX = Monitor.new
9
+
6
10
  class << self
7
11
  def parsers
8
- @parsers ||= default_parsers
12
+ @parsers || MUTEX.synchronize { @parsers ||= default_parsers.freeze }
9
13
  end
10
14
 
11
15
  def register(parser)
12
- parsers.unshift(parser)
16
+ MUTEX.synchronize do
17
+ current = @parsers || default_parsers.freeze
18
+ @parsers = ([parser] + current).freeze
19
+ end
13
20
  end
14
21
 
15
22
  def find_for(url)
16
23
  parsers.find { |parser| parser.match?(url) }
17
24
  end
18
25
 
26
+ def find_for_provider(provider)
27
+ provider_name = provider.to_s
28
+ parsers.find { |parser| parser.provider_names.include?(provider_name) }
29
+ end
30
+
19
31
  def reset!
20
- @parsers = nil
32
+ MUTEX.synchronize { @parsers = nil }
21
33
  end
22
34
 
23
35
  private
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Parsers
7
+ module SSE
8
+ DONE_MARKER = "[DONE]"
9
+
10
+ class << self
11
+ def parse(body)
12
+ return [] if body.nil? || body.empty?
13
+
14
+ return parse_json_array(body) if probably_json_array?(body)
15
+
16
+ parse_event_stream(body)
17
+ end
18
+
19
+ private
20
+
21
+ def parse_event_stream(body)
22
+ events = []
23
+ current_event = nil
24
+ data_lines = []
25
+
26
+ body.each_line do |raw|
27
+ line = raw.chomp
28
+
29
+ if line.empty?
30
+ events << finalize_event(current_event, data_lines) if data_lines.any?
31
+ current_event = nil
32
+ data_lines = []
33
+ next
34
+ end
35
+
36
+ next if line.start_with?(":")
37
+
38
+ field, _, value = line.partition(":")
39
+ value = value[1..] if value.start_with?(" ")
40
+
41
+ case field
42
+ when "event" then current_event = value
43
+ when "data" then data_lines << value
44
+ end
45
+ end
46
+
47
+ events << finalize_event(current_event, data_lines) if data_lines.any?
48
+ events.compact
49
+ end
50
+
51
+ def parse_json_array(body)
52
+ parsed = JSON.parse(body)
53
+ return [] unless parsed.is_a?(Array)
54
+
55
+ parsed.map { |entry| { event: nil, data: entry } }
56
+ rescue JSON::ParserError
57
+ []
58
+ end
59
+
60
+ def finalize_event(event_name, data_lines)
61
+ payload = data_lines.join("\n")
62
+ return nil if payload == DONE_MARKER
63
+
64
+ { event: event_name, data: decode_data(payload) }
65
+ end
66
+
67
+ def decode_data(payload)
68
+ return payload if payload.empty?
69
+
70
+ JSON.parse(payload)
71
+ rescue JSON::ParserError
72
+ payload
73
+ end
74
+
75
+ def probably_json_array?(body)
76
+ body.lstrip.start_with?("[")
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
12
12
  EMPTY_PRICES = {}.freeze
13
13
  PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
14
- METADATA_KEYS = %w[_source _updated _notes].freeze
14
+ METADATA_KEYS = %w[_source _source_version _fetched_at _updated _notes _validator_override].freeze
15
15
  MUTEX = Monitor.new
16
16
 
17
17
  class << self
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "net/http"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module LlmCostTracker
9
+ module PriceSync
10
+ class Fetcher
11
+ Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
12
+ def source_version
13
+ etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
14
+ end
15
+ end
16
+
17
+ USER_AGENT = "llm_cost_tracker price sync"
18
+ MAX_REDIRECTS = 5
19
+ OPEN_TIMEOUT = 5
20
+ READ_TIMEOUT = 10
21
+ WRITE_TIMEOUT = 10
22
+
23
+ def get(url, etag: nil, redirects: 0)
24
+ raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
25
+
26
+ uri = URI.parse(url)
27
+ request = Net::HTTP::Get.new(uri)
28
+ request["User-Agent"] = USER_AGENT
29
+ request["If-None-Match"] = etag if etag
30
+
31
+ response = Net::HTTP.start(
32
+ uri.host,
33
+ uri.port,
34
+ use_ssl: uri.scheme == "https",
35
+ open_timeout: OPEN_TIMEOUT,
36
+ read_timeout: READ_TIMEOUT,
37
+ write_timeout: WRITE_TIMEOUT
38
+ ) do |http|
39
+ http.request(request)
40
+ end
41
+
42
+ case response
43
+ when Net::HTTPSuccess
44
+ build_response(response, not_modified: false)
45
+ when Net::HTTPNotModified
46
+ build_response(response, body: nil, not_modified: true)
47
+ when Net::HTTPRedirection
48
+ location = response["location"]
49
+ raise Error, "Redirect without location while fetching #{url}" if location.nil? || location.empty?
50
+
51
+ get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
52
+ else
53
+ raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
54
+ end
55
+ rescue SocketError, SystemCallError, Timeout::Error => e
56
+ raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ def build_response(response, not_modified:, body: response.body)
62
+ Response.new(
63
+ body: body,
64
+ etag: response["etag"],
65
+ last_modified: response["last-modified"],
66
+ not_modified: not_modified,
67
+ fetched_at: Time.now.utc.iso8601
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class Merger
6
+ Discrepancy = Data.define(:model, :field, :values)
7
+
8
+ PRIORITY_ORDER = %i[litellm openrouter].freeze
9
+ SUPPLEMENTAL_FIELDS = %i[cached_input cache_read_input cache_creation_input].freeze
10
+
11
+ def merge(results_by_source)
12
+ prices = collect_prices(results_by_source)
13
+ discrepancies = []
14
+
15
+ merged = prices.group_by(&:model).sort.to_h.transform_values do |candidates|
16
+ sorted = sort_candidates(candidates)
17
+ discrepancies.concat(detect_discrepancies(sorted))
18
+ fill_missing_fields(sorted.first, sorted.drop(1))
19
+ end
20
+
21
+ [merged, discrepancies]
22
+ end
23
+
24
+ private
25
+
26
+ def collect_prices(results_by_source)
27
+ results_by_source.flat_map do |source_name, result|
28
+ result.prices.map do |price|
29
+ price.with(source: source_name)
30
+ end
31
+ end
32
+ end
33
+
34
+ def sort_candidates(candidates)
35
+ candidates.sort_by do |price|
36
+ PRIORITY_ORDER.index(price.source.to_sym) || PRIORITY_ORDER.length
37
+ end
38
+ end
39
+
40
+ def fill_missing_fields(primary, fallbacks)
41
+ SUPPLEMENTAL_FIELDS.reduce(primary) do |current, field|
42
+ next current if current.public_send(field)
43
+
44
+ fallback = fallbacks.find { |candidate| candidate.public_send(field) }
45
+ fallback ? current.with(field => fallback.public_send(field)) : current
46
+ end
47
+ end
48
+
49
+ def detect_discrepancies(candidates)
50
+ return [] if candidates.length < 2
51
+
52
+ RawPrice::PRICE_FIELDS.filter_map do |field|
53
+ values = candidates.each_with_object({}) do |price, collected|
54
+ value = price.public_send(field)
55
+ collected[price.source] = value unless value.nil?
56
+ end
57
+ next if values.size < 2
58
+ next unless discrepant?(values.values)
59
+
60
+ Discrepancy.new(model: candidates.first.model, field: field, values: values)
61
+ end
62
+ end
63
+
64
+ def discrepant?(values)
65
+ min, max = values.minmax
66
+ return max != min if min.to_f.zero?
67
+
68
+ ((max - min).abs / min.to_f) >= 0.05
69
+ end
70
+ end
71
+ end
72
+ end
@@ -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,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