llm_cost_tracker 0.4.0 → 0.5.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +195 -109
  4. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +46 -55
  5. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
  6. data/lib/llm_cost_tracker/budget.rb +34 -37
  7. data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
  8. data/lib/llm_cost_tracker/configuration.rb +10 -5
  9. data/lib/llm_cost_tracker/doctor.rb +166 -0
  10. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
  11. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +38 -8
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +1 -2
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
  15. data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
  16. data/lib/llm_cost_tracker/integrations/base.rb +72 -0
  17. data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
  18. data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
  19. data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
  20. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -3
  21. data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
  22. data/lib/llm_cost_tracker/parsers/anthropic.rb +17 -49
  23. data/lib/llm_cost_tracker/parsers/base.rb +80 -0
  24. data/lib/llm_cost_tracker/parsers/gemini.rb +12 -35
  25. data/lib/llm_cost_tracker/parsers/openai.rb +1 -6
  26. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +6 -15
  27. data/lib/llm_cost_tracker/parsers/openai_usage.rb +8 -30
  28. data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
  29. data/lib/llm_cost_tracker/price_freshness.rb +38 -0
  30. data/lib/llm_cost_tracker/price_registry.rb +14 -0
  31. data/lib/llm_cost_tracker/price_sync/fetcher.rb +2 -1
  32. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +4 -2
  33. data/lib/llm_cost_tracker/price_sync.rb +10 -0
  34. data/lib/llm_cost_tracker/prices.json +394 -41
  35. data/lib/llm_cost_tracker/pricing.rb +8 -1
  36. data/lib/llm_cost_tracker/request_url.rb +20 -0
  37. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +47 -27
  38. data/lib/llm_cost_tracker/storage/active_record_store.rb +4 -0
  39. data/lib/llm_cost_tracker/stream_collector.rb +3 -3
  40. data/lib/llm_cost_tracker/tag_context.rb +52 -0
  41. data/lib/llm_cost_tracker/tags_column.rb +62 -24
  42. data/lib/llm_cost_tracker/tracker.rb +5 -2
  43. data/lib/llm_cost_tracker/version.rb +1 -1
  44. data/lib/llm_cost_tracker.rb +14 -4
  45. data/lib/tasks/llm_cost_tracker.rake +21 -3
  46. metadata +13 -3
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
 
7
5
  module LlmCostTracker
@@ -10,10 +8,7 @@ module LlmCostTracker
10
8
  HOSTS = %w[api.anthropic.com].freeze
11
9
 
12
10
  def match?(url)
13
- uri = URI.parse(url.to_s)
14
- HOSTS.include?(uri.host.to_s.downcase) && uri.path.include?("/v1/messages")
15
- rescue URI::InvalidURIError
16
- false
11
+ match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
17
12
  end
18
13
 
19
14
  def provider_names
@@ -52,25 +47,25 @@ module LlmCostTracker
52
47
  usage = stream_usage(events)
53
48
  response_id = stream_response_id(events)
54
49
 
55
- usage ? build_stream_result(model, usage, response_id) : build_unknown_stream_result(model, response_id)
50
+ if usage
51
+ build_stream_result(model, usage, response_id)
52
+ else
53
+ build_unknown_stream_usage(
54
+ provider: "anthropic",
55
+ model: model,
56
+ provider_response_id: response_id
57
+ )
58
+ end
56
59
  end
57
60
 
58
61
  private
59
62
 
60
63
  def stream_usage(events)
61
- start_usage = nil
62
- latest_delta = nil
63
-
64
- events.each do |event|
65
- data = event[:data]
66
- next unless data.is_a?(Hash)
67
-
68
- case data["type"]
69
- when "message_start"
70
- start_usage = data.dig("message", "usage")
71
- when "message_delta"
72
- latest_delta = data["usage"] if data["usage"].is_a?(Hash)
73
- end
64
+ start_usage = find_event_value(events, reverse: true) do |data|
65
+ data.dig("message", "usage") if data["type"] == "message_start"
66
+ end
67
+ latest_delta = find_event_value(events, reverse: true) do |data|
68
+ data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
74
69
  end
75
70
 
76
71
  return nil unless start_usage || latest_delta
@@ -81,25 +76,11 @@ module LlmCostTracker
81
76
  end
82
77
 
83
78
  def stream_model(events)
84
- events.each do |event|
85
- data = event[:data]
86
- next unless data.is_a?(Hash)
87
-
88
- model = data.dig("message", "model")
89
- return model if model && !model.empty?
90
- end
91
- nil
79
+ find_event_value(events) { |data| data.dig("message", "model") }
92
80
  end
93
81
 
94
82
  def stream_response_id(events)
95
- events.each do |event|
96
- data = event[:data]
97
- next unless data.is_a?(Hash)
98
-
99
- id = data.dig("message", "id") || data["id"]
100
- return id if id && !id.to_s.empty?
101
- end
102
- nil
83
+ find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
103
84
  end
104
85
 
105
86
  def build_stream_result(model, usage, response_id)
@@ -121,19 +102,6 @@ module LlmCostTracker
121
102
  usage_source: :stream_final
122
103
  )
123
104
  end
124
-
125
- def build_unknown_stream_result(model, response_id)
126
- ParsedUsage.build(
127
- provider: "anthropic",
128
- provider_response_id: response_id,
129
- model: model,
130
- input_tokens: 0,
131
- output_tokens: 0,
132
- total_tokens: 0,
133
- stream: true,
134
- usage_source: :unknown
135
- )
136
- end
137
105
  end
138
106
  end
139
107
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Parsers
@@ -40,6 +41,85 @@ module LlmCostTracker
40
41
  rescue JSON::ParserError
41
42
  {}
42
43
  end
44
+
45
+ def uri_matches?(url)
46
+ uri = parsed_uri(url)
47
+ uri ? yield(uri) : false
48
+ end
49
+
50
+ def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
51
+ uri_matches?(url) do |uri|
52
+ host_match = hosts.nil? || host_matches?(uri, hosts)
53
+ path_match = path_matches?(
54
+ uri,
55
+ exact_paths: exact_paths,
56
+ path_includes: path_includes,
57
+ path_suffixes: path_suffixes,
58
+ path_pattern: path_pattern
59
+ )
60
+ extra_match = block_given? ? yield(uri) : true
61
+
62
+ host_match && path_match && extra_match ? true : false
63
+ end
64
+ end
65
+
66
+ def parsed_uri(url)
67
+ URI.parse(url.to_s)
68
+ rescue URI::InvalidURIError
69
+ nil
70
+ end
71
+
72
+ def host_matches?(uri, hosts)
73
+ hosts.include?(uri.host.to_s.downcase)
74
+ end
75
+
76
+ def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
77
+ path = uri.path.to_s
78
+ matches = true
79
+
80
+ matches &&= exact_paths.include?(path) if exact_paths
81
+ matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
82
+ matches &&= path.match?(path_pattern) if path_pattern
83
+
84
+ matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
85
+
86
+ matches
87
+ end
88
+
89
+ def each_event_data(events, reverse: false)
90
+ enumerator = reverse ? events.reverse_each : events.each
91
+
92
+ enumerator.each do |event|
93
+ data = event[:data]
94
+ yield data if data.is_a?(Hash)
95
+ end
96
+ end
97
+
98
+ def find_event_value(events, reverse: false)
99
+ each_event_data(events, reverse:) do |data|
100
+ value = yield(data)
101
+ return value if event_value_present?(value)
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ def build_unknown_stream_usage(provider:, model:, provider_response_id:)
108
+ ParsedUsage.build(
109
+ provider: provider,
110
+ provider_response_id: provider_response_id,
111
+ model: model || ParsedUsage::UNKNOWN_MODEL,
112
+ input_tokens: 0,
113
+ output_tokens: 0,
114
+ total_tokens: 0,
115
+ stream: true,
116
+ usage_source: :unknown
117
+ )
118
+ end
119
+
120
+ def event_value_present?(value)
121
+ !value.nil? && (!value.respond_to?(:empty?) || !value.empty?)
122
+ end
43
123
  end
44
124
  end
45
125
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
 
7
5
  module LlmCostTracker
@@ -12,10 +10,7 @@ module LlmCostTracker
12
10
  STREAM_PATH_PATTERN = /:streamGenerateContent\z/
13
11
 
14
12
  def match?(url)
15
- uri = URI.parse(url.to_s)
16
- HOSTS.include?(uri.host.to_s.downcase) && uri.path.match?(TRACKED_PATH_PATTERN)
17
- rescue URI::InvalidURIError
18
- false
13
+ match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
19
14
  end
20
15
 
21
16
  def provider_names
@@ -48,6 +43,7 @@ module LlmCostTracker
48
43
 
49
44
  usage = merged_stream_usage(events)
50
45
  model = extract_model_from_url(request_url)
46
+ response_id = stream_response_id(events)
51
47
 
52
48
  if usage
53
49
  build_parsed_usage(
@@ -55,18 +51,13 @@ module LlmCostTracker
55
51
  usage,
56
52
  stream: true,
57
53
  usage_source: :stream_final,
58
- provider_response_id: stream_response_id(events)
54
+ provider_response_id: response_id
59
55
  )
60
56
  else
61
- ParsedUsage.build(
57
+ build_unknown_stream_usage(
62
58
  provider: "gemini",
63
- provider_response_id: stream_response_id(events),
64
59
  model: model,
65
- input_tokens: 0,
66
- output_tokens: 0,
67
- total_tokens: 0,
68
- stream: true,
69
- usage_source: :unknown
60
+ provider_response_id: response_id
70
61
  )
71
62
  end
72
63
  end
@@ -91,15 +82,10 @@ module LlmCostTracker
91
82
  end
92
83
 
93
84
  def merged_stream_usage(events)
94
- latest = nil
95
- events.each do |event|
96
- data = event[:data]
97
- next unless data.is_a?(Hash)
98
-
85
+ find_event_value(events, reverse: true) do |data|
99
86
  meta = data["usageMetadata"]
100
- latest = meta if meta.is_a?(Hash)
87
+ meta if meta.is_a?(Hash)
101
88
  end
102
- latest
103
89
  end
104
90
 
105
91
  def output_tokens(usage)
@@ -107,28 +93,19 @@ module LlmCostTracker
107
93
  end
108
94
 
109
95
  def stream_response_id(events)
110
- events.each do |event|
111
- data = event[:data]
112
- next unless data.is_a?(Hash)
113
-
114
- id = data["responseId"]
115
- return id if id && !id.to_s.empty?
116
- end
117
- nil
96
+ find_event_value(events) { |data| data["responseId"] }
118
97
  end
119
98
 
120
99
  def streaming_url?(request_url)
121
- URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
122
- rescue URI::InvalidURIError
123
- false
100
+ match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
124
101
  end
125
102
 
126
103
  def extract_model_from_url(url)
127
- uri = URI.parse(url.to_s)
104
+ uri = parsed_uri(url)
105
+ return nil unless uri
106
+
128
107
  match = uri.path.match(%r{/models/([^/:]+)})
129
108
  match && match[1]
130
- rescue URI::InvalidURIError
131
- nil
132
109
  end
133
110
  end
134
111
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
  require_relative "openai_usage"
7
5
 
@@ -14,10 +12,7 @@ module LlmCostTracker
14
12
  TRACKED_PATHS = %w[/v1/chat/completions /v1/completions /v1/embeddings /v1/responses].freeze
15
13
 
16
14
  def match?(url)
17
- uri = URI.parse(url.to_s)
18
- HOSTS.include?(uri.host.to_s.downcase) && TRACKED_PATHS.include?(uri.path)
19
- rescue URI::InvalidURIError
20
- false
15
+ match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
21
16
  end
22
17
 
23
18
  def provider_names
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
  require_relative "openai_usage"
7
5
 
@@ -13,10 +11,7 @@ module LlmCostTracker
13
11
  TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
14
12
 
15
13
  def match?(url)
16
- uri = URI.parse(url.to_s)
17
- !provider_for_host(uri.host).nil? && tracked_path?(uri.path)
18
- rescue URI::InvalidURIError
19
- false
14
+ match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
20
15
  end
21
16
 
22
17
  def provider_names
@@ -37,18 +32,14 @@ module LlmCostTracker
37
32
  private
38
33
 
39
34
  def provider_for(request_url)
40
- uri = URI.parse(request_url.to_s)
41
- provider_for_host(uri.host) || "openai_compatible"
42
- rescue URI::InvalidURIError
43
- "openai_compatible"
35
+ uri = parsed_uri(request_url)
36
+ provider_for_uri(uri) || "openai_compatible"
44
37
  end
45
38
 
46
- def provider_for_host(host)
47
- LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
48
- end
39
+ def provider_for_uri(uri)
40
+ return nil unless uri
49
41
 
50
- def tracked_path?(path)
51
- TRACKED_PATH_SUFFIXES.any? { |suffix| path == suffix || path.end_with?(suffix) }
42
+ LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
52
43
  end
53
44
  end
54
45
  end
@@ -34,12 +34,13 @@ module LlmCostTracker
34
34
  request = safe_json_parse(request_body)
35
35
  model = detect_stream_model(events) || request["model"]
36
36
  usage = detect_stream_usage(events)
37
+ response_id = detect_stream_response_id(events)
37
38
 
38
39
  if usage
39
40
  cache_read = cache_read_input_tokens(usage)
40
41
  ParsedUsage.build(
41
42
  provider: provider_for(request_url),
42
- provider_response_id: detect_stream_response_id(events),
43
+ provider_response_id: response_id,
43
44
  model: model,
44
45
  input_tokens: regular_input_tokens(usage, cache_read),
45
46
  output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
@@ -50,50 +51,27 @@ module LlmCostTracker
50
51
  usage_source: :stream_final
51
52
  )
52
53
  else
53
- ParsedUsage.build(
54
+ build_unknown_stream_usage(
54
55
  provider: provider_for(request_url),
55
- provider_response_id: detect_stream_response_id(events),
56
56
  model: model,
57
- input_tokens: 0,
58
- output_tokens: 0,
59
- total_tokens: 0,
60
- stream: true,
61
- usage_source: :unknown
57
+ provider_response_id: response_id
62
58
  )
63
59
  end
64
60
  end
65
61
 
66
62
  def detect_stream_usage(events)
67
- events.reverse_each do |event|
68
- data = event[:data]
69
- next unless data.is_a?(Hash)
70
-
63
+ find_event_value(events, reverse: true) do |data|
71
64
  usage = data["usage"]
72
- return usage if usage.is_a?(Hash) && !usage.empty?
65
+ usage if usage.is_a?(Hash)
73
66
  end
74
- nil
75
67
  end
76
68
 
77
69
  def detect_stream_model(events)
78
- events.each do |event|
79
- data = event[:data]
80
- next unless data.is_a?(Hash)
81
-
82
- model = data["model"]
83
- return model if model && !model.to_s.empty?
84
- end
85
- nil
70
+ find_event_value(events) { |data| data["model"] || data.dig("response", "model") }
86
71
  end
87
72
 
88
73
  def detect_stream_response_id(events)
89
- events.each do |event|
90
- data = event[:data]
91
- next unless data.is_a?(Hash)
92
-
93
- id = data["id"] || data.dig("response", "id")
94
- return id if id && !id.to_s.empty?
95
- end
96
- nil
74
+ find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
97
75
  end
98
76
 
99
77
  def regular_input_tokens(usage, cache_read)
@@ -13,10 +13,14 @@ module LlmCostTracker
13
13
  end
14
14
 
15
15
  def register(parser)
16
+ parser = coerce_parser(parser)
17
+
16
18
  MUTEX.synchronize do
17
19
  current = @parsers || default_parsers.freeze
18
20
  @parsers = ([parser] + current).freeze
19
21
  end
22
+
23
+ parser
20
24
  end
21
25
 
22
26
  def find_for(url)
@@ -24,8 +28,8 @@ module LlmCostTracker
24
28
  end
25
29
 
26
30
  def find_for_provider(provider)
27
- provider_name = provider.to_s
28
- parsers.find { |parser| parser.provider_names.include?(provider_name) }
31
+ provider_name = provider.to_s.downcase
32
+ parsers.find { |parser| provider_names_for(parser).include?(provider_name) }
29
33
  end
30
34
 
31
35
  def reset!
@@ -34,6 +38,17 @@ module LlmCostTracker
34
38
 
35
39
  private
36
40
 
41
+ def coerce_parser(parser)
42
+ return parser.new if parser.is_a?(Class) && parser <= Base
43
+ return parser if parser.is_a?(Base)
44
+
45
+ raise ArgumentError, "parser must be a LlmCostTracker::Parsers::Base instance or class"
46
+ end
47
+
48
+ def provider_names_for(parser)
49
+ Array(parser.provider_names).map { |name| name.to_s.downcase }
50
+ end
51
+
37
52
  def default_parsers
38
53
  [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
39
54
  end
@@ -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:sync"]
21
+ end
22
+
23
+ private
24
+
25
+ def missing
26
+ [:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:sync"]
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:sync"
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
 
@@ -52,7 +53,7 @@ module LlmCostTracker
52
53
  else
53
54
  raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
54
55
  end
55
- rescue SocketError, SystemCallError, Timeout::Error => e
56
+ rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
56
57
  raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
57
58
  end
58
59
 
@@ -31,7 +31,7 @@ module LlmCostTracker
31
31
  ),
32
32
  accepted: validated.accepted,
33
33
  changes: price_changes(current_models, updated_models),
34
- orphaned_models: compute_orphaned(current_models, merged.keys),
34
+ orphaned_models: compute_orphaned(current_models, merged.keys, source_results),
35
35
  failed_sources: failed_sources,
36
36
  discrepancies: discrepancies,
37
37
  rejected: validated.rejected,
@@ -70,7 +70,9 @@ module LlmCostTracker
70
70
  merged.sort.to_h
71
71
  end
72
72
 
73
- def compute_orphaned(current_models, merged_models)
73
+ def compute_orphaned(current_models, merged_models, source_results)
74
+ return [] if source_results.empty?
75
+
74
76
  seed_models(current_models).keys.reject do |model|
75
77
  manual_model?(current_models[model]) || merged_models.include?(model)
76
78
  end.sort
@@ -67,6 +67,16 @@ module LlmCostTracker
67
67
  end
68
68
 
69
69
  class << self
70
+ def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
71
+ output = env["OUTPUT"].to_s.strip
72
+ return output unless output.empty?
73
+
74
+ prices_file = config.prices_file
75
+ return prices_file.to_s if prices_file
76
+
77
+ nil
78
+ end
79
+
70
80
  def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
71
81
  fetcher: Fetcher.new, today: Date.today)
72
82
  plan = RefreshPlanBuilder.new(sources: sources).call(