llm_cost_tracker 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +116 -467
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
  5. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
  6. data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
  8. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
  9. data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
  10. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
  11. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
  12. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
  13. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
  14. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  15. data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
  16. data/lib/llm_cost_tracker/configuration.rb +22 -16
  17. data/lib/llm_cost_tracker/doctor.rb +1 -1
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
  20. data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
  21. data/lib/llm_cost_tracker/integrations/base.rb +77 -6
  22. data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
  23. data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
  24. data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
  25. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
  26. data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
  27. data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
  28. data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
  29. data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
  30. data/lib/llm_cost_tracker/price_freshness.rb +3 -3
  31. data/lib/llm_cost_tracker/price_registry.rb +3 -0
  32. data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
  33. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  34. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
  35. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  36. data/lib/llm_cost_tracker/price_sync.rb +103 -111
  37. data/lib/llm_cost_tracker/prices.json +225 -229
  38. data/lib/llm_cost_tracker/pricing.rb +27 -15
  39. data/lib/llm_cost_tracker/report.rb +8 -1
  40. data/lib/llm_cost_tracker/report_data.rb +25 -9
  41. data/lib/llm_cost_tracker/retention.rb +30 -7
  42. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  43. data/lib/llm_cost_tracker/stream_capture.rb +7 -0
  44. data/lib/llm_cost_tracker/stream_collector.rb +25 -1
  45. data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
  46. data/lib/llm_cost_tracker/tracker.rb +7 -59
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +1 -0
  49. data/lib/tasks/llm_cost_tracker.rake +24 -78
  50. metadata +26 -15
  51. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  52. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  53. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  54. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
  55. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  56. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  57. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  58. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  59. data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  # rubocop:disable Metrics/BlockLength
4
6
  namespace :llm_cost_tracker do
5
7
  desc "Check LLM Cost Tracker setup"
@@ -26,56 +28,49 @@ namespace :llm_cost_tracker do
26
28
 
27
29
  namespace :prices do
28
30
  desc(
29
- "Sync the configured pricing file from LiteLLM/OpenRouter JSON sources. " \
30
- "Use PREVIEW=1 to preview, STRICT=1 to fail on provider errors, " \
31
- "or OUTPUT=path/to/file.json."
31
+ "Refresh the configured pricing file from the maintained LLM Cost Tracker price snapshot. " \
32
+ "Use PREVIEW=1 to preview, URL=... to override the source, or OUTPUT=path/to/file.json."
32
33
  )
33
- task :sync do
34
+ task :refresh do
34
35
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
35
36
  require_relative "../llm_cost_tracker"
36
37
 
37
- output_path = price_sync_output_path
38
- strict = ENV["STRICT"] == "1" || ARGV.include?("--strict")
39
- result = LlmCostTracker::PriceSync.sync(
38
+ output_path = price_refresh_output_path
39
+ source_url = LlmCostTracker::PriceSync.configured_remote_url
40
+ preview = ENV["PREVIEW"] == "1"
41
+ result = LlmCostTracker::PriceSync.refresh(
40
42
  path: output_path,
41
- preview: ENV["PREVIEW"] == "1",
42
- strict: strict
43
+ url: source_url,
44
+ preview: preview
43
45
  )
44
46
 
45
- action = if ENV["PREVIEW"] == "1"
47
+ action = if preview
46
48
  "previewed"
47
49
  elsif result.written
48
- "updated"
50
+ "refreshed"
49
51
  else
50
52
  "kept"
51
53
  end
52
54
 
53
55
  puts "llm_cost_tracker: #{action} pricing file #{result.path}"
54
- print_source_usage(result.sources_used)
56
+ puts " source: #{result.source_url}"
57
+ puts " version: #{result.source_version.inspect}" if result.source_version
55
58
  print_changes(result.changes)
56
- print_discrepancies(result.discrepancies)
57
- print_issues("validator rejected", result.rejected)
58
- print_issues("validator flagged", result.flagged)
59
- print_models("orphaned models (no JSON source match)", result.orphaned_models)
60
- print_failures(result.failed_sources, heading: "source failures (kept existing values)")
61
59
  end
62
60
 
63
- desc "Compare the current pricing snapshot with LiteLLM/OpenRouter JSON sources and exit non-zero on drift."
61
+ desc "Compare the current pricing file with the maintained LLM Cost Tracker price snapshot."
64
62
  task :check do
65
63
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
66
64
  require_relative "../llm_cost_tracker"
67
65
 
68
- output_path = price_sync_output_path
69
- result = LlmCostTracker::PriceSync.check(path: output_path)
66
+ output_path = price_refresh_output_path
67
+ source_url = LlmCostTracker::PriceSync.configured_remote_url
68
+ result = LlmCostTracker::PriceSync.check(path: output_path, url: source_url)
70
69
 
71
70
  puts "llm_cost_tracker: checked pricing file #{result.path}"
72
- print_source_usage(result.sources_used)
71
+ puts " source: #{result.source_url}"
72
+ puts " version: #{result.source_version.inspect}" if result.source_version
73
73
  print_changes(result.changes)
74
- print_discrepancies(result.discrepancies)
75
- print_issues("validator rejected", result.rejected)
76
- print_issues("validator flagged", result.flagged)
77
- print_models("orphaned models (no JSON source match)", result.orphaned_models)
78
- print_failures(result.failed_sources, heading: "source failures")
79
74
  puts " pricing is up to date" if result.up_to_date
80
75
  abort("llm_cost_tracker: pricing check failed") unless result.up_to_date
81
76
  end
@@ -83,16 +78,6 @@ namespace :llm_cost_tracker do
83
78
  end
84
79
  # rubocop:enable Metrics/BlockLength
85
80
 
86
- def print_source_usage(sources_used)
87
- return if sources_used.empty?
88
-
89
- puts " sources used:"
90
- sources_used.each do |source, usage|
91
- version = usage.source_version ? ", version=#{usage.source_version.inspect}" : ""
92
- puts " - #{source} (#{usage.prices_count} prices#{version})"
93
- end
94
- end
95
-
96
81
  def print_changes(changes)
97
82
  puts " changed models: #{changes.size}"
98
83
  return if changes.empty?
@@ -105,47 +90,8 @@ def print_changes(changes)
105
90
  end
106
91
  end
107
92
 
108
- def print_discrepancies(discrepancies)
109
- return if discrepancies.empty?
110
-
111
- puts " source discrepancies: #{discrepancies.size}"
112
- discrepancies.each do |issue|
113
- formatted = issue.values.map { |source, value| "#{source}=#{value.inspect}" }.join(", ")
114
- puts " - #{issue.model} #{issue.field}: #{formatted}"
115
- end
116
- end
117
-
118
- def print_issues(heading, issues)
119
- return if issues.empty?
120
-
121
- puts " #{heading}: #{issues.size}"
122
- issues.each do |issue|
123
- puts " - #{issue.model}: #{issue.reason}"
124
- end
125
- end
126
-
127
- def print_models(heading, models)
128
- return if models.empty?
129
-
130
- puts " #{heading}: #{models.size}"
131
- models.each { |model| puts " - #{model}" }
132
- end
133
-
134
- def print_failures(failed_sources, heading:)
135
- return if failed_sources.empty?
136
-
137
- puts " #{heading}: #{failed_sources.size}"
138
- failed_sources.each do |source, message|
139
- puts " - #{source}: #{message}"
140
- end
141
- end
142
-
143
- def price_sync_output_path
93
+ def price_refresh_output_path
144
94
  path = LlmCostTracker::PriceSync.configured_output_path
145
- return path if path
146
-
147
- abort(
148
- "llm_cost_tracker: configure prices_file, run bin/rails generate llm_cost_tracker:prices, " \
149
- "or set OUTPUT=config/llm_cost_tracker_prices.yml"
150
- )
95
+ FileUtils.mkdir_p(File.dirname(path))
96
+ path
151
97
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-25 00:00:00.000000000 Z
11
+ date: 2026-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -84,6 +84,20 @@ dependencies:
84
84
  - - "<"
85
85
  - !ruby/object:Gem::Version
86
86
  version: '9.0'
87
+ - !ruby/object:Gem::Dependency
88
+ name: nokogiri
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '1.16'
94
+ type: :development
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '1.16'
87
101
  - !ruby/object:Gem::Dependency
88
102
  name: railties
89
103
  requirement: !ruby/object:Gem::Requirement
@@ -208,10 +222,10 @@ dependencies:
208
222
  - - "~>"
209
223
  - !ruby/object:Gem::Version
210
224
  version: '3.0'
211
- description: Tracks token usage, latency, and estimated costs for OpenAI, Anthropic,
212
- Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works through Faraday
213
- middleware or explicit track/track_stream helpers, with ActiveRecord storage, tag-based
214
- attribution, price sync tasks, and budget guardrails.
225
+ description: Tracks token usage, latency, and estimated costs for RubyLLM, OpenAI,
226
+ Anthropic, Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works
227
+ through Faraday middleware or explicit track/track_stream helpers, with ActiveRecord
228
+ storage, tag-based attribution, price sync tasks, and budget guardrails.
215
229
  email:
216
230
  - sergey@mm.st
217
231
  executables: []
@@ -241,6 +255,7 @@ files:
241
255
  - app/helpers/llm_cost_tracker/pagination_helper.rb
242
256
  - app/services/llm_cost_tracker/dashboard/data_quality.rb
243
257
  - app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb
258
+ - app/services/llm_cost_tracker/dashboard/date_range.rb
244
259
  - app/services/llm_cost_tracker/dashboard/filter.rb
245
260
  - app/services/llm_cost_tracker/dashboard/overview_stats.rb
246
261
  - app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
@@ -303,6 +318,7 @@ files:
303
318
  - lib/llm_cost_tracker/integrations/object_reader.rb
304
319
  - lib/llm_cost_tracker/integrations/openai.rb
305
320
  - lib/llm_cost_tracker/integrations/registry.rb
321
+ - lib/llm_cost_tracker/integrations/ruby_llm.rb
306
322
  - lib/llm_cost_tracker/llm_api_call.rb
307
323
  - lib/llm_cost_tracker/logging.rb
308
324
  - lib/llm_cost_tracker/middleware/faraday.rb
@@ -322,17 +338,9 @@ files:
322
338
  - lib/llm_cost_tracker/price_registry.rb
323
339
  - lib/llm_cost_tracker/price_sync.rb
324
340
  - lib/llm_cost_tracker/price_sync/fetcher.rb
325
- - lib/llm_cost_tracker/price_sync/merger.rb
326
- - lib/llm_cost_tracker/price_sync/model_catalog.rb
327
- - lib/llm_cost_tracker/price_sync/raw_price.rb
328
- - lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb
341
+ - lib/llm_cost_tracker/price_sync/registry_diff.rb
329
342
  - lib/llm_cost_tracker/price_sync/registry_loader.rb
330
343
  - lib/llm_cost_tracker/price_sync/registry_writer.rb
331
- - lib/llm_cost_tracker/price_sync/source.rb
332
- - lib/llm_cost_tracker/price_sync/source_result.rb
333
- - lib/llm_cost_tracker/price_sync/sources/litellm.rb
334
- - lib/llm_cost_tracker/price_sync/sources/open_router.rb
335
- - lib/llm_cost_tracker/price_sync/validator.rb
336
344
  - lib/llm_cost_tracker/prices.json
337
345
  - lib/llm_cost_tracker/pricing.rb
338
346
  - lib/llm_cost_tracker/railtie.rb
@@ -343,11 +351,14 @@ files:
343
351
  - lib/llm_cost_tracker/retention.rb
344
352
  - lib/llm_cost_tracker/storage/active_record_rollups.rb
345
353
  - lib/llm_cost_tracker/storage/active_record_store.rb
354
+ - lib/llm_cost_tracker/storage/dispatcher.rb
355
+ - lib/llm_cost_tracker/stream_capture.rb
346
356
  - lib/llm_cost_tracker/stream_collector.rb
347
357
  - lib/llm_cost_tracker/tag_accessors.rb
348
358
  - lib/llm_cost_tracker/tag_context.rb
349
359
  - lib/llm_cost_tracker/tag_key.rb
350
360
  - lib/llm_cost_tracker/tag_query.rb
361
+ - lib/llm_cost_tracker/tag_sanitizer.rb
351
362
  - lib/llm_cost_tracker/tags_column.rb
352
363
  - lib/llm_cost_tracker/tracker.rb
353
364
  - lib/llm_cost_tracker/unknown_pricing.rb
@@ -1,72 +0,0 @@
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[cache_read_input cache_write_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
@@ -1,77 +0,0 @@
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
@@ -1,33 +0,0 @@
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
- :cache_read_input,
11
- :cache_write_input,
12
- :source,
13
- :source_version,
14
- :fetched_at
15
- )
16
-
17
- class RawPrice
18
- PRICE_FIELDS = %w[input output cache_read_input cache_write_input].freeze
19
-
20
- def to_registry_entry(today:)
21
- {
22
- "input" => input,
23
- "output" => output,
24
- "cache_read_input" => cache_read_input,
25
- "cache_write_input" => cache_write_input,
26
- "_source" => source.to_s,
27
- "_source_version" => source_version,
28
- "_fetched_at" => fetched_at || today.iso8601
29
- }.compact
30
- end
31
- end
32
- end
33
- end
@@ -1,164 +0,0 @@
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, source_results),
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, source_results)
74
- return [] if source_results.empty?
75
-
76
- seed_models(current_models).keys.reject do |model|
77
- manual_model?(current_models[model]) || merged_models.include?(model)
78
- end.sort
79
- end
80
-
81
- def seed_models(current_models)
82
- normalize_models(current_models).transform_values do |entry|
83
- next entry if entry.key?("_source")
84
-
85
- entry.merge("_source" => "seed")
86
- end
87
- end
88
-
89
- def normalize_models(models)
90
- normalize_hash(models).each_with_object({}) do |(model, entry), normalized|
91
- normalized[model.to_s] = normalize_hash(entry)
92
- end
93
- end
94
-
95
- def normalize_hash(hash)
96
- return {} if hash.nil?
97
- raise ArgumentError, "price sync entries must be hashes" unless hash.is_a?(Hash)
98
-
99
- hash.each_with_object({}) do |(key, value), normalized|
100
- normalized[key.to_s] = value
101
- end
102
- end
103
-
104
- def manual_model?(entry)
105
- normalize_hash(entry)["_source"] == "manual"
106
- end
107
-
108
- def registry_entry_for(existing_entry, price, today)
109
- normalize_hash(existing_entry)
110
- .except(*PriceRegistry::PRICE_KEYS)
111
- .merge(price.to_registry_entry(today: today))
112
- end
113
-
114
- def updated_metadata(existing, today, refresh_succeeded:, source_results:)
115
- metadata = normalize_hash(existing)
116
- metadata["currency"] ||= "USD"
117
- metadata["unit"] ||= "1M tokens"
118
- return metadata unless refresh_succeeded
119
-
120
- metadata["updated_at"] = today.iso8601
121
- metadata["source_urls"] = source_urls(source_results)
122
- metadata
123
- end
124
-
125
- def source_usage(source_results)
126
- source_results.transform_values do |result|
127
- PriceSync::SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
128
- end
129
- end
130
-
131
- def price_changes(current_models, updated_models)
132
- current_models = normalize_models(current_models)
133
- updated_models = normalize_models(updated_models)
134
-
135
- (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
136
- fields = price_field_changes(current_models[model], updated_models[model])
137
- changes[model] = fields if fields.any?
138
- end
139
- end
140
-
141
- def price_field_changes(current_entry, updated_entry)
142
- current_price = comparable_price(current_entry)
143
- updated_price = comparable_price(updated_entry)
144
-
145
- (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
146
- from = current_price[field]
147
- to = updated_price[field]
148
- next if from == to
149
-
150
- changes[field] = { "from" => from, "to" => to }
151
- end
152
- end
153
-
154
- def comparable_price(entry)
155
- normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
156
- end
157
-
158
- def source_urls(source_results)
159
- names = source_results.keys.map(&:to_sym)
160
- sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
161
- end
162
- end
163
- end
164
- end
@@ -1,29 +0,0 @@
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
@@ -1,7 +0,0 @@
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