llm_cost_tracker 0.3.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +14 -1
  4. data/app/assets/llm_cost_tracker/application.css +1 -4
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  7. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  10. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  11. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +10 -10
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
  14. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  15. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  16. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  17. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  18. data/app/views/llm_cost_tracker/calls/index.html.erb +13 -13
  19. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  20. data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
  22. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  23. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  24. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  25. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  27. data/lib/llm_cost_tracker/configuration.rb +0 -1
  28. data/lib/llm_cost_tracker/event.rb +1 -0
  29. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
  33. data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
  34. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
  35. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  36. data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
  37. data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
  38. data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
  39. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
  40. data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
  41. data/lib/llm_cost_tracker/price_registry.rb +22 -7
  42. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  43. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  44. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  45. data/lib/llm_cost_tracker/price_sync.rb +16 -184
  46. data/lib/llm_cost_tracker/pricing.rb +0 -11
  47. data/lib/llm_cost_tracker/railtie.rb +0 -1
  48. data/lib/llm_cost_tracker/report.rb +0 -5
  49. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
  50. data/lib/llm_cost_tracker/stream_collector.rb +17 -13
  51. data/lib/llm_cost_tracker/tags_column.rb +4 -0
  52. data/lib/llm_cost_tracker/tracker.rb +10 -2
  53. data/lib/llm_cost_tracker/version.rb +1 -1
  54. data/lib/llm_cost_tracker.rb +6 -14
  55. metadata +7 -1
@@ -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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PriceSync
5
+ class RefreshPlanBuilder
6
+ def initialize(sources:, loader: RegistryLoader.new)
7
+ @sources = sources
8
+ @loader = loader
9
+ end
10
+
11
+ def call(path:, seed_path:, fetcher:, today:)
12
+ path = path.to_s
13
+ registry = loader.call(path: path, seed_path: seed_path)
14
+ current_models = registry.fetch("models", {})
15
+ source_results, failed_sources = fetch_all(current_models, fetcher)
16
+ merged, discrepancies = Merger.new.merge(source_results)
17
+ validated = Validator.new.validate_batch(merged, existing_registry: current_models)
18
+ updated_models = apply_changes(current_models, validated.accepted, today)
19
+
20
+ PriceSync::RefreshPlan.new(
21
+ path: path,
22
+ registry: registry,
23
+ updated_registry: registry.merge(
24
+ "metadata" => updated_metadata(
25
+ registry["metadata"],
26
+ today,
27
+ refresh_succeeded: source_results.any? { |_source, result| result.prices.any? },
28
+ source_results: source_results
29
+ ),
30
+ "models" => updated_models
31
+ ),
32
+ accepted: validated.accepted,
33
+ changes: price_changes(current_models, updated_models),
34
+ orphaned_models: compute_orphaned(current_models, merged.keys),
35
+ failed_sources: failed_sources,
36
+ discrepancies: discrepancies,
37
+ rejected: validated.rejected,
38
+ flagged: validated.flagged,
39
+ sources_used: source_usage(source_results),
40
+ source_results: source_results
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :sources, :loader
47
+
48
+ def fetch_all(current_models, fetcher)
49
+ results = {}
50
+ failures = {}
51
+
52
+ sources.each do |source|
53
+ results[source.name.to_sym] = source.fetch(current_models: current_models, fetcher: fetcher)
54
+ rescue Error => e
55
+ failures[source.name.to_sym] = e.message
56
+ end
57
+
58
+ [results, failures]
59
+ end
60
+
61
+ def apply_changes(current_models, accepted, today)
62
+ merged = seed_models(current_models)
63
+
64
+ accepted.each do |model, price|
65
+ next if manual_model?(merged[model])
66
+
67
+ merged[model] = registry_entry_for(merged[model], price, today)
68
+ end
69
+
70
+ merged.sort.to_h
71
+ end
72
+
73
+ def compute_orphaned(current_models, merged_models)
74
+ seed_models(current_models).keys.reject do |model|
75
+ manual_model?(current_models[model]) || merged_models.include?(model)
76
+ end.sort
77
+ end
78
+
79
+ def seed_models(current_models)
80
+ normalize_models(current_models).transform_values do |entry|
81
+ next entry if entry.key?("_source")
82
+
83
+ entry.merge("_source" => "seed")
84
+ end
85
+ end
86
+
87
+ def normalize_models(models)
88
+ normalize_hash(models).each_with_object({}) do |(model, entry), normalized|
89
+ normalized[model.to_s] = normalize_hash(entry)
90
+ end
91
+ end
92
+
93
+ def normalize_hash(hash)
94
+ return {} if hash.nil?
95
+ raise ArgumentError, "price sync entries must be hashes" unless hash.is_a?(Hash)
96
+
97
+ hash.each_with_object({}) do |(key, value), normalized|
98
+ normalized[key.to_s] = value
99
+ end
100
+ end
101
+
102
+ def manual_model?(entry)
103
+ normalize_hash(entry)["_source"] == "manual"
104
+ end
105
+
106
+ def registry_entry_for(existing_entry, price, today)
107
+ normalize_hash(existing_entry)
108
+ .except(*PriceRegistry::PRICE_KEYS)
109
+ .merge(price.to_registry_entry(today: today))
110
+ end
111
+
112
+ def updated_metadata(existing, today, refresh_succeeded:, source_results:)
113
+ metadata = normalize_hash(existing)
114
+ metadata["currency"] ||= "USD"
115
+ metadata["unit"] ||= "1M tokens"
116
+ return metadata unless refresh_succeeded
117
+
118
+ metadata["updated_at"] = today.iso8601
119
+ metadata["source_urls"] = source_urls(source_results)
120
+ metadata
121
+ end
122
+
123
+ def source_usage(source_results)
124
+ source_results.transform_values do |result|
125
+ PriceSync::SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
126
+ end
127
+ end
128
+
129
+ def price_changes(current_models, updated_models)
130
+ current_models = normalize_models(current_models)
131
+ updated_models = normalize_models(updated_models)
132
+
133
+ (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
134
+ fields = price_field_changes(current_models[model], updated_models[model])
135
+ changes[model] = fields if fields.any?
136
+ end
137
+ end
138
+
139
+ def price_field_changes(current_entry, updated_entry)
140
+ current_price = comparable_price(current_entry)
141
+ updated_price = comparable_price(updated_entry)
142
+
143
+ (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
144
+ from = current_price[field]
145
+ to = updated_price[field]
146
+ next if from == to
147
+
148
+ changes[field] = { "from" => from, "to" => to }
149
+ end
150
+ end
151
+
152
+ def comparable_price(entry)
153
+ normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
154
+ end
155
+
156
+ def source_urls(source_results)
157
+ names = source_results.keys.map(&:to_sym)
158
+ sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module LlmCostTracker
7
+ module PriceSync
8
+ class RegistryLoader
9
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
10
+
11
+ def call(path:, seed_path:)
12
+ source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
13
+ normalize_registry(load_registry_file(source_path))
14
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
15
+ raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
16
+ end
17
+
18
+ private
19
+
20
+ def load_registry_file(path)
21
+ contents = File.read(path)
22
+ registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
23
+ raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
24
+
25
+ registry
26
+ end
27
+
28
+ def normalize_registry(registry)
29
+ {
30
+ "metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
31
+ "models" => normalize_models(registry.fetch("models", {}))
32
+ }
33
+ end
34
+
35
+ def normalize_models(models)
36
+ normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
37
+ normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
38
+ end
39
+ end
40
+
41
+ def normalize_hash(hash, label:)
42
+ return {} if hash.nil?
43
+ raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
44
+
45
+ hash.each_with_object({}) do |(key, value), normalized|
46
+ normalized[key.to_s] = value
47
+ end
48
+ end
49
+
50
+ def yaml_file?(path)
51
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ module LlmCostTracker
8
+ module PriceSync
9
+ class RegistryWriter
10
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
11
+
12
+ def call(path:, registry:)
13
+ FileUtils.mkdir_p(File.dirname(path))
14
+ payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
15
+ File.write(path, payload)
16
+ end
17
+
18
+ private
19
+
20
+ def yaml_file?(path)
21
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
- require "fileutils"
5
- require "json"
6
- require "yaml"
7
4
 
8
5
  require_relative "price_sync/fetcher"
9
6
  require_relative "price_sync/raw_price"
10
7
  require_relative "price_sync/source"
11
8
  require_relative "price_sync/source_result"
9
+ require_relative "price_sync/registry_loader"
10
+ require_relative "price_sync/registry_writer"
11
+ require_relative "price_sync/refresh_plan_builder"
12
12
  require_relative "price_sync/model_catalog"
13
13
  require_relative "price_sync/merger"
14
14
  require_relative "price_sync/validator"
@@ -16,10 +16,8 @@ require_relative "price_sync/sources/litellm"
16
16
  require_relative "price_sync/sources/open_router"
17
17
 
18
18
  module LlmCostTracker
19
- # rubocop:disable Metrics/ModuleLength, Metrics/ClassLength
20
19
  module PriceSync
21
20
  DEFAULT_OUTPUT_PATH = PriceRegistry::DEFAULT_PRICES_PATH
22
- YAML_EXTENSIONS = %w[.yml .yaml].freeze
23
21
 
24
22
  SourceUsage = Data.define(:prices_count, :source_version)
25
23
  SyncResult = Data.define(
@@ -71,11 +69,16 @@ module LlmCostTracker
71
69
  class << self
72
70
  def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
73
71
  fetcher: Fetcher.new, today: Date.today)
74
- plan = build_refresh_plan(path: path, seed_path: seed_path, fetcher: fetcher, today: today)
72
+ plan = RefreshPlanBuilder.new(sources: sources).call(
73
+ path: path,
74
+ seed_path: seed_path,
75
+ fetcher: fetcher,
76
+ today: today
77
+ )
75
78
  raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
76
79
 
77
80
  written = !preview && plan.refresh_succeeded?
78
- write_registry(plan.path, plan.updated_registry) if written
81
+ RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
79
82
 
80
83
  SyncResult.new(
81
84
  path: plan.path,
@@ -92,7 +95,12 @@ module LlmCostTracker
92
95
  end
93
96
 
94
97
  def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
95
- plan = build_refresh_plan(path: path, seed_path: seed_path, fetcher: fetcher, today: today)
98
+ plan = RefreshPlanBuilder.new(sources: sources).call(
99
+ path: path,
100
+ seed_path: seed_path,
101
+ fetcher: fetcher,
102
+ today: today
103
+ )
96
104
 
97
105
  CheckResult.new(
98
106
  path: plan.path,
@@ -113,166 +121,6 @@ module LlmCostTracker
113
121
  [Sources::Litellm.new, Sources::OpenRouter.new]
114
122
  end
115
123
 
116
- def build_refresh_plan(path:, seed_path:, fetcher:, today:)
117
- path = path.to_s
118
- registry = load_registry(path, seed_path: seed_path)
119
- current_models = registry.fetch("models", {})
120
- source_results, failed_sources = fetch_all(current_models, fetcher)
121
- merged, discrepancies = Merger.new.merge(source_results)
122
- validated = Validator.new.validate_batch(merged, existing_registry: current_models)
123
- updated_models = apply_changes(current_models, validated.accepted, today)
124
- refresh_succeeded = source_results.any? { |_source, result| result.prices.any? }
125
-
126
- RefreshPlan.new(
127
- path: path,
128
- registry: registry,
129
- updated_registry: registry.merge(
130
- "metadata" => updated_metadata(
131
- registry["metadata"],
132
- today,
133
- refresh_succeeded: refresh_succeeded,
134
- source_results: source_results
135
- ),
136
- "models" => updated_models
137
- ),
138
- accepted: validated.accepted,
139
- changes: price_changes(current_models, updated_models),
140
- orphaned_models: compute_orphaned(current_models, merged.keys),
141
- failed_sources: failed_sources,
142
- discrepancies: discrepancies,
143
- rejected: validated.rejected,
144
- flagged: validated.flagged,
145
- sources_used: source_usage(source_results),
146
- source_results: source_results
147
- )
148
- end
149
-
150
- def fetch_all(current_models, fetcher)
151
- results = {}
152
- failures = {}
153
-
154
- sources.each do |source|
155
- results[source.name.to_sym] = source.fetch(current_models: current_models, fetcher: fetcher)
156
- rescue Error => e
157
- failures[source.name.to_sym] = e.message
158
- end
159
-
160
- [results, failures]
161
- end
162
-
163
- def apply_changes(current_models, accepted, today)
164
- merged = seed_models(current_models)
165
-
166
- accepted.each do |model, price|
167
- next if manual_model?(merged[model])
168
-
169
- merged[model] = registry_entry_for(merged[model], price, today)
170
- end
171
-
172
- merged.sort.to_h
173
- end
174
-
175
- def compute_orphaned(current_models, merged_models)
176
- seed_models(current_models).keys.reject do |model|
177
- manual_model?(current_models[model]) || merged_models.include?(model)
178
- end.sort
179
- end
180
-
181
- def load_registry(path, seed_path:)
182
- source_path = File.exist?(path) ? path : seed_path.to_s
183
- normalize_registry(load_registry_file(source_path))
184
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
185
- raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
186
- end
187
-
188
- def load_registry_file(path)
189
- contents = File.read(path)
190
- return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
191
-
192
- JSON.parse(contents)
193
- end
194
-
195
- def normalize_registry(registry)
196
- {
197
- "metadata" => normalize_hash(registry.fetch("metadata", {})),
198
- "models" => normalize_models(registry.fetch("models", {}))
199
- }
200
- end
201
-
202
- def normalize_models(models)
203
- (models || {}).each_with_object({}) do |(model, entry), normalized|
204
- normalized[model.to_s] = normalize_hash(entry)
205
- end
206
- end
207
-
208
- def normalize_hash(hash)
209
- (hash || {}).each_with_object({}) do |(key, value), normalized|
210
- normalized[key.to_s] = value
211
- end
212
- end
213
-
214
- def seed_models(current_models)
215
- normalize_models(current_models).transform_values do |entry|
216
- next entry if entry.key?("_source")
217
-
218
- entry.merge("_source" => "seed")
219
- end
220
- end
221
-
222
- def manual_model?(entry)
223
- normalize_hash(entry)["_source"] == "manual"
224
- end
225
-
226
- def registry_entry_for(existing_entry, price, today)
227
- normalize_hash(existing_entry)
228
- .except(*PriceRegistry::PRICE_KEYS)
229
- .merge(price.to_registry_entry(today: today))
230
- end
231
-
232
- def updated_metadata(existing, today, refresh_succeeded:, source_results:)
233
- metadata = normalize_hash(existing)
234
- metadata["currency"] ||= "USD"
235
- metadata["unit"] ||= "1M tokens"
236
- return metadata unless refresh_succeeded
237
-
238
- metadata["updated_at"] = today.iso8601
239
- metadata["source_urls"] = source_urls(source_results)
240
- metadata
241
- end
242
-
243
- def source_usage(source_results)
244
- source_results.transform_values do |result|
245
- SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
246
- end
247
- end
248
-
249
- def price_changes(current_models, updated_models)
250
- current_models = normalize_models(current_models)
251
- updated_models = normalize_models(updated_models)
252
-
253
- (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
254
- fields = price_field_changes(current_models[model], updated_models[model])
255
- changes[model] = fields if fields.any?
256
- end
257
- end
258
-
259
- def price_field_changes(current_entry, updated_entry)
260
- current_price = comparable_price(current_entry)
261
- updated_price = comparable_price(updated_entry)
262
-
263
- (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
264
- from = current_price[field]
265
- to = updated_price[field]
266
- next if from == to
267
-
268
- changes[field] = { "from" => from, "to" => to }
269
- end
270
- end
271
-
272
- def comparable_price(entry)
273
- normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
274
- end
275
-
276
124
  def strict_sync_failure?(plan, strict:)
277
125
  strict && (plan.failed_sources.any? || plan.rejected.any?)
278
126
  end
@@ -289,22 +137,6 @@ module LlmCostTracker
289
137
  end
290
138
  "Price sync failed in strict mode: #{messages.join('; ')}"
291
139
  end
292
-
293
- def source_urls(source_results)
294
- names = source_results.keys.map(&:to_sym)
295
- sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
296
- end
297
-
298
- def write_registry(path, registry)
299
- FileUtils.mkdir_p(File.dirname(path))
300
- payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
301
- File.write(path, payload)
302
- end
303
-
304
- def yaml_file?(path)
305
- YAML_EXTENSIONS.include?(File.extname(path).downcase)
306
- end
307
140
  end
308
141
  end
309
- # rubocop:enable Metrics/ModuleLength, Metrics/ClassLength
310
142
  end
@@ -3,21 +3,11 @@
3
3
  require "monitor"
4
4
 
5
5
  module LlmCostTracker
6
- # Calculates costs from price entries expressed in USD per 1M tokens.
7
6
  module Pricing
8
7
  PRICES = PriceRegistry.builtin_prices
9
8
  MUTEX = Monitor.new
10
9
 
11
10
  class << self
12
- # Estimate model cost from token counts.
13
- #
14
- # @param model [String] Provider model identifier.
15
- # @param input_tokens [Integer] Input token count, including cached tokens if reported that way.
16
- # @param output_tokens [Integer] Output token count.
17
- # @param cached_input_tokens [Integer] OpenAI-style cached input tokens.
18
- # @param cache_read_input_tokens [Integer] Anthropic-style cache read tokens.
19
- # @param cache_creation_input_tokens [Integer] Anthropic-style cache creation tokens.
20
- # @return [LlmCostTracker::Cost, nil] nil when no price is configured for the model.
21
11
  def cost_for(model:, input_tokens:, output_tokens:, cached_input_tokens: 0,
22
12
  cache_read_input_tokens: 0, cache_creation_input_tokens: 0)
23
13
  prices = lookup(model)
@@ -111,7 +101,6 @@ module LlmCostTracker
111
101
  model.to_s.split("/").last
112
102
  end
113
103
 
114
- # Try to match model names like "gpt-4o-2024-08-06" to "gpt-4o".
115
104
  def fuzzy_match(model, normalized_model, table)
116
105
  sorted_price_keys(table).each do |key|
117
106
  return table[key] if model.start_with?(key) || normalized_model.start_with?(key)
@@ -15,7 +15,6 @@ module LlmCostTracker
15
15
  end
16
16
 
17
17
  initializer "llm_cost_tracker.configure" do
18
- # Auto-require ActiveRecord storage if configured
19
18
  ActiveSupport.on_load(:active_record) do
20
19
  if LlmCostTracker.configuration.active_record?
21
20
  require_relative "llm_api_call"
@@ -8,11 +8,6 @@ module LlmCostTracker
8
8
  DEFAULT_DAYS = ReportData::DEFAULT_DAYS
9
9
 
10
10
  class << self
11
- # Render a terminal-friendly cost report from ActiveRecord storage.
12
- #
13
- # @param days [Integer] Number of trailing days to include.
14
- # @param now [Time] Report end time.
15
- # @return [String]
16
11
  def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
17
12
  ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
18
13
  rescue LoadError => e
@@ -19,24 +19,23 @@ module LlmCostTracker
19
19
  tags: tags_for_storage(tags),
20
20
  tracked_at: event.tracked_at
21
21
  }
22
- attributes[:latency_ms] = event.latency_ms if model_class.latency_column?
23
- attributes[:stream] = event.stream if model_class.stream_column?
24
- attributes[:usage_source] = event.usage_source if model_class.usage_source_column?
25
-
26
- model_class.create!(attributes)
22
+ attributes[:latency_ms] = event.latency_ms if LlmCostTracker::LlmApiCall.latency_column?
23
+ attributes[:stream] = event.stream if LlmCostTracker::LlmApiCall.stream_column?
24
+ attributes[:usage_source] = event.usage_source if LlmCostTracker::LlmApiCall.usage_source_column?
25
+ if LlmCostTracker::LlmApiCall.provider_response_id_column?
26
+ attributes[:provider_response_id] = event.provider_response_id
27
+ end
28
+
29
+ LlmCostTracker::LlmApiCall.create!(attributes)
27
30
  end
28
31
 
29
32
  def monthly_total(time: Time.now.utc)
30
- model_class
33
+ LlmCostTracker::LlmApiCall
31
34
  .where(tracked_at: time.beginning_of_month..time)
32
35
  .sum(:total_cost)
33
36
  .to_f
34
37
  end
35
38
 
36
- def model_class
37
- LlmCostTracker::LlmApiCall
38
- end
39
-
40
39
  private
41
40
 
42
41
  def stringify_tags(tags)
@@ -44,7 +43,7 @@ module LlmCostTracker
44
43
  end
45
44
 
46
45
  def tags_for_storage(tags)
47
- model_class.tags_json_column? ? tags : tags.to_json
46
+ LlmCostTracker::LlmApiCall.tags_json_column? ? tags : tags.to_json
48
47
  end
49
48
 
50
49
  def stringify_tag_value(value)