llm_cost_tracker 0.2.0 → 0.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +124 -68
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +1 -4
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  10. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
  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 +16 -1
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
  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 +23 -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 +11 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
  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/assets.rb +6 -11
  31. data/lib/llm_cost_tracker/configuration.rb +78 -43
  32. data/lib/llm_cost_tracker/event.rb +3 -0
  33. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  40. data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
  41. data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
  42. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  43. data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
  44. data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
  45. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  46. data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
  47. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  48. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
  50. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  51. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +23 -8
  53. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  54. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  55. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  56. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  57. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  58. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  59. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  60. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  61. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  62. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  63. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  64. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  65. data/lib/llm_cost_tracker/price_sync.rb +142 -0
  66. data/lib/llm_cost_tracker/pricing.rb +0 -11
  67. data/lib/llm_cost_tracker/railtie.rb +0 -1
  68. data/lib/llm_cost_tracker/report.rb +0 -5
  69. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
  70. data/lib/llm_cost_tracker/stream_collector.rb +162 -0
  71. data/lib/llm_cost_tracker/tags_column.rb +12 -0
  72. data/lib/llm_cost_tracker/tracker.rb +23 -12
  73. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. data/lib/llm_cost_tracker.rb +48 -35
  76. data/lib/tasks/llm_cost_tracker.rake +116 -0
  77. data/llm_cost_tracker.gemspec +8 -6
  78. metadata +30 -8
@@ -5,19 +5,31 @@ require "json"
5
5
  module LlmCostTracker
6
6
  module Parsers
7
7
  class Base
8
- # Parse a provider response into a {LlmCostTracker::ParsedUsage}, or return
9
- # nil when the response is not trackable (non-200, missing usage, etc).
10
- #
11
- # @return [LlmCostTracker::ParsedUsage, nil]
12
8
  def parse(request_url, request_body, response_status, response_body)
13
9
  raise NotImplementedError
14
10
  end
15
11
 
16
- # Returns true if this parser can handle the given URL.
12
+ def provider_names
13
+ []
14
+ end
15
+
17
16
  def match?(url)
18
17
  raise NotImplementedError
19
18
  end
20
19
 
20
+ def streaming_request?(_request_url, request_body)
21
+ return false if request_body.nil?
22
+
23
+ body = request_body.to_s
24
+ return false if body.empty?
25
+
26
+ body.include?('"stream":true') || body.include?('"stream": true') || body.include?("stream: true")
27
+ end
28
+
29
+ def parse_stream(_request_url, _request_body, _response_status, _events)
30
+ nil
31
+ end
32
+
21
33
  private
22
34
 
23
35
  def safe_json_parse(body)
@@ -9,6 +9,7 @@ module LlmCostTracker
9
9
  class Gemini < Base
10
10
  HOSTS = %w[generativelanguage.googleapis.com].freeze
11
11
  TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
12
+ STREAM_PATH_PATTERN = /:streamGenerateContent\z/
12
13
 
13
14
  def match?(url)
14
15
  uri = URI.parse(url.to_s)
@@ -17,6 +18,16 @@ module LlmCostTracker
17
18
  false
18
19
  end
19
20
 
21
+ def provider_names
22
+ %w[gemini]
23
+ end
24
+
25
+ def streaming_request?(request_url, request_body)
26
+ return true if streaming_url?(request_url)
27
+
28
+ super
29
+ end
30
+
20
31
  def parse(request_url, _request_body, response_status, response_body)
21
32
  return nil unless response_status == 200
22
33
 
@@ -24,31 +35,97 @@ module LlmCostTracker
24
35
  usage = response["usageMetadata"]
25
36
  return nil unless usage
26
37
 
27
- # Extract model from URL: /v1beta/models/gemini-2.5-flash:generateContent
38
+ build_parsed_usage(
39
+ request_url,
40
+ usage,
41
+ usage_source: :response,
42
+ provider_response_id: response["responseId"]
43
+ )
44
+ end
45
+
46
+ def parse_stream(request_url, _request_body, response_status, events)
47
+ return nil unless response_status == 200
48
+
49
+ usage = merged_stream_usage(events)
28
50
  model = extract_model_from_url(request_url)
29
51
 
52
+ if usage
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
+ )
60
+ else
61
+ ParsedUsage.build(
62
+ provider: "gemini",
63
+ provider_response_id: stream_response_id(events),
64
+ model: model,
65
+ input_tokens: 0,
66
+ output_tokens: 0,
67
+ total_tokens: 0,
68
+ stream: true,
69
+ usage_source: :unknown
70
+ )
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def build_parsed_usage(request_url, usage, usage_source:, stream: false, provider_response_id: nil)
30
77
  ParsedUsage.build(
31
78
  provider: "gemini",
32
- model: model,
79
+ model: extract_model_from_url(request_url),
33
80
  input_tokens: usage["promptTokenCount"].to_i,
34
81
  output_tokens: output_tokens(usage),
35
82
  total_tokens: usage["totalTokenCount"].to_i,
36
- cached_input_tokens: usage["cachedContentTokenCount"]
83
+ cached_input_tokens: usage["cachedContentTokenCount"],
84
+ stream: stream,
85
+ usage_source: usage_source,
86
+ provider_response_id: provider_response_id
37
87
  )
38
88
  end
39
89
 
40
- private
90
+ def merged_stream_usage(events)
91
+ latest = nil
92
+ events.each do |event|
93
+ data = event[:data]
94
+ next unless data.is_a?(Hash)
95
+
96
+ meta = data["usageMetadata"]
97
+ latest = meta if meta.is_a?(Hash)
98
+ end
99
+ latest
100
+ end
41
101
 
42
102
  def output_tokens(usage)
43
103
  usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
44
104
  end
45
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
+
117
+ def streaming_url?(request_url)
118
+ URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
119
+ rescue URI::InvalidURIError
120
+ false
121
+ end
122
+
46
123
  def extract_model_from_url(url)
47
124
  uri = URI.parse(url.to_s)
48
125
  match = uri.path.match(%r{/models/([^/:]+)})
49
- match ? match[1] : "unknown"
126
+ match && match[1]
50
127
  rescue URI::InvalidURIError
51
- "unknown"
128
+ nil
52
129
  end
53
130
  end
54
131
  end
@@ -20,10 +20,18 @@ module LlmCostTracker
20
20
  false
21
21
  end
22
22
 
23
+ def provider_names
24
+ %w[openai]
25
+ end
26
+
23
27
  def parse(request_url, request_body, response_status, response_body)
24
28
  parse_openai_usage(request_url, request_body, response_status, response_body)
25
29
  end
26
30
 
31
+ def parse_stream(request_url, request_body, response_status, events)
32
+ parse_openai_stream_usage(request_url, request_body, response_status, events)
33
+ end
34
+
27
35
  private
28
36
 
29
37
  def provider_for(_request_url)
@@ -19,10 +19,21 @@ module LlmCostTracker
19
19
  false
20
20
  end
21
21
 
22
+ def provider_names
23
+ [
24
+ "openai_compatible",
25
+ *LlmCostTracker.configuration.openai_compatible_providers.each_value.map(&:to_s)
26
+ ].uniq.freeze
27
+ end
28
+
22
29
  def parse(request_url, request_body, response_status, response_body)
23
30
  parse_openai_usage(request_url, request_body, response_status, response_body)
24
31
  end
25
32
 
33
+ def parse_stream(request_url, request_body, response_status, events)
34
+ parse_openai_stream_usage(request_url, request_body, response_status, events)
35
+ end
36
+
26
37
  private
27
38
 
28
39
  def provider_for(request_url)
@@ -33,11 +44,7 @@ module LlmCostTracker
33
44
  end
34
45
 
35
46
  def provider_for_host(host)
36
- configured_providers[host.to_s.downcase]&.to_s
37
- end
38
-
39
- def configured_providers
40
- LlmCostTracker.configuration.openai_compatible_providers
47
+ LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
41
48
  end
42
49
 
43
50
  def tracked_path?(path)
@@ -16,14 +16,82 @@ 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,
22
23
  total_tokens: usage["total_tokens"].to_i,
23
- cached_input_tokens: cached_input_tokens(usage)
24
+ cached_input_tokens: cached_input_tokens(usage),
25
+ usage_source: :response
24
26
  )
25
27
  end
26
28
 
29
+ def parse_openai_stream_usage(request_url, request_body, response_status, events)
30
+ return nil unless response_status == 200
31
+
32
+ request = safe_json_parse(request_body)
33
+ model = detect_stream_model(events) || request["model"]
34
+ usage = detect_stream_usage(events)
35
+
36
+ if usage
37
+ ParsedUsage.build(
38
+ provider: provider_for(request_url),
39
+ provider_response_id: detect_stream_response_id(events),
40
+ model: model,
41
+ input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
42
+ output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
43
+ total_tokens: usage["total_tokens"].to_i,
44
+ cached_input_tokens: cached_input_tokens(usage),
45
+ stream: true,
46
+ usage_source: :stream_final
47
+ )
48
+ else
49
+ ParsedUsage.build(
50
+ provider: provider_for(request_url),
51
+ provider_response_id: detect_stream_response_id(events),
52
+ model: model,
53
+ input_tokens: 0,
54
+ output_tokens: 0,
55
+ total_tokens: 0,
56
+ stream: true,
57
+ usage_source: :unknown
58
+ )
59
+ end
60
+ end
61
+
62
+ def detect_stream_usage(events)
63
+ events.reverse_each do |event|
64
+ data = event[:data]
65
+ next unless data.is_a?(Hash)
66
+
67
+ usage = data["usage"]
68
+ return usage if usage.is_a?(Hash) && !usage.empty?
69
+ end
70
+ nil
71
+ end
72
+
73
+ def detect_stream_model(events)
74
+ events.each do |event|
75
+ data = event[:data]
76
+ next unless data.is_a?(Hash)
77
+
78
+ model = data["model"]
79
+ return model if model && !model.to_s.empty?
80
+ end
81
+ nil
82
+ end
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
+
27
95
  def cached_input_tokens(usage)
28
96
  details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
29
97
  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
@@ -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,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