llm_cost_tracker 0.4.1 → 0.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +132 -405
  4. data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
  5. data/lib/llm_cost_tracker/configuration.rb +10 -5
  6. data/lib/llm_cost_tracker/doctor.rb +166 -0
  7. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
  10. data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
  11. data/lib/llm_cost_tracker/integrations/base.rb +72 -0
  12. data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
  13. data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
  14. data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
  15. data/lib/llm_cost_tracker/middleware/faraday.rb +6 -5
  16. data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
  17. data/lib/llm_cost_tracker/parsers/base.rb +1 -1
  18. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  19. data/lib/llm_cost_tracker/price_freshness.rb +38 -0
  20. data/lib/llm_cost_tracker/price_registry.rb +14 -0
  21. data/lib/llm_cost_tracker/price_sync/fetcher.rb +5 -2
  22. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  23. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  24. data/lib/llm_cost_tracker/price_sync.rb +111 -109
  25. data/lib/llm_cost_tracker/prices.json +391 -42
  26. data/lib/llm_cost_tracker/pricing.rb +35 -16
  27. data/lib/llm_cost_tracker/request_url.rb +20 -0
  28. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  29. data/lib/llm_cost_tracker/stream_collector.rb +3 -3
  30. data/lib/llm_cost_tracker/tag_context.rb +52 -0
  31. data/lib/llm_cost_tracker/tracker.rb +7 -60
  32. data/lib/llm_cost_tracker/version.rb +1 -1
  33. data/lib/llm_cost_tracker.rb +14 -4
  34. data/lib/tasks/llm_cost_tracker.rake +33 -69
  35. metadata +28 -12
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
  37. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  38. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  39. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  40. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -162
  41. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  42. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  43. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  44. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  45. data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+
5
+ require_relative "value_helpers"
6
+
7
+ module LlmCostTracker
8
+ module TagContext
9
+ KEY = :llm_cost_tracker_tags
10
+
11
+ class << self
12
+ def with(tags)
13
+ stack = current_stack
14
+ ActiveSupport::IsolatedExecutionState[KEY] = stack + [normalize(tags)]
15
+ yield
16
+ ensure
17
+ ActiveSupport::IsolatedExecutionState[KEY] = stack
18
+ end
19
+
20
+ def tags
21
+ config_tags.merge(scoped_tags)
22
+ end
23
+
24
+ def clear!
25
+ ActiveSupport::IsolatedExecutionState[KEY] = []
26
+ end
27
+
28
+ private
29
+
30
+ def config_tags
31
+ normalize(resolve_default_tags)
32
+ end
33
+
34
+ def resolve_default_tags
35
+ tags = LlmCostTracker.configuration.default_tags
36
+ tags.respond_to?(:call) ? tags.call : tags
37
+ end
38
+
39
+ def scoped_tags
40
+ current_stack.reduce({}) { |merged, tags| merged.merge(tags) }
41
+ end
42
+
43
+ def current_stack
44
+ ActiveSupport::IsolatedExecutionState[KEY] || []
45
+ end
46
+
47
+ def normalize(tags)
48
+ ValueHelpers.deep_dup(tags || {}).to_h
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "logging"
3
+ require_relative "storage/dispatcher"
4
4
 
5
5
  module LlmCostTracker
6
6
  class Tracker
7
7
  EVENT_NAME = "llm_request.llm_cost_tracker"
8
8
 
9
- USAGE_SOURCES = %i[response stream_final manual unknown].freeze
9
+ USAGE_SOURCES = %i[response stream_final sdk_response manual unknown].freeze
10
10
 
11
11
  class << self
12
12
  def enforce_budget!
@@ -19,6 +19,7 @@ module LlmCostTracker
19
19
  usage_source: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
20
20
  return unless LlmCostTracker.configuration.enabled
21
21
 
22
+ model = normalize_model(model)
22
23
  usage = usage_data(input_tokens, output_tokens, metadata, pricing_mode)
23
24
  cost_data = cost_for_usage(provider, model, usage)
24
25
 
@@ -38,7 +39,7 @@ module LlmCostTracker
38
39
 
39
40
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
40
41
 
41
- stored = store(event)
42
+ stored = Storage::Dispatcher.save(event)
42
43
  Budget.check!(event) unless stored == false
43
44
 
44
45
  event
@@ -68,6 +69,8 @@ module LlmCostTracker
68
69
  )
69
70
  end
70
71
 
72
+ def normalize_model(value) = value.to_s.strip.then { |model| model.empty? ? ParsedUsage::UNKNOWN_MODEL : model }
73
+
71
74
  def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
72
75
  provider_response_id:)
73
76
  Event.new(
@@ -81,7 +84,7 @@ module LlmCostTracker
81
84
  hidden_output_tokens: usage[:hidden_output_tokens],
82
85
  pricing_mode: usage[:pricing_mode],
83
86
  cost: cost_data,
84
- tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)).freeze,
87
+ tags: LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)).freeze,
85
88
  latency_ms: normalized_latency_ms(latency_ms),
86
89
  stream: stream ? true : false,
87
90
  usage_source: normalized_usage_source(usage_source),
@@ -90,62 +93,6 @@ module LlmCostTracker
90
93
  )
91
94
  end
92
95
 
93
- def store(event)
94
- config = LlmCostTracker.configuration
95
- case config.storage_backend
96
- when :log then log_event(event, config)
97
- when :active_record then active_record_save(event)
98
- when :custom then custom_save(event, config)
99
- end
100
- rescue BudgetExceededError, UnknownPricingError
101
- raise
102
- rescue StandardError => e
103
- handle_storage_error(e)
104
- false
105
- end
106
-
107
- def log_event(event, config)
108
- message = "#{event.provider}/#{event.model} " \
109
- "tokens=#{event.total_tokens} " \
110
- "cost=#{log_cost_label(event)}"
111
- message += " latency=#{event.latency_ms}ms" if event.latency_ms
112
- message += " stream=#{event.stream}" if event.stream
113
- message += " source=#{event.usage_source}" if event.usage_source
114
- message += " tags=#{event.tags}" unless event.tags.empty?
115
-
116
- Logging.log(config.log_level, message)
117
- event
118
- end
119
-
120
- def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
121
-
122
- def active_record_save(event)
123
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
124
- require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
125
-
126
- Storage::ActiveRecordStore.save(event)
127
- event
128
- rescue LoadError => e
129
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
130
- end
131
-
132
- def custom_save(event, config)
133
- result = config.custom_storage&.call(event)
134
- result == false ? false : event
135
- end
136
-
137
- def handle_storage_error(error)
138
- case LlmCostTracker.configuration.storage_error_behavior
139
- when :ignore
140
- nil
141
- when :warn
142
- Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
143
- when :raise
144
- storage_error = StorageError.new(error)
145
- raise storage_error
146
- end
147
- end
148
-
149
96
  def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
150
97
 
151
98
  def normalized_usage_source(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -25,9 +25,11 @@ require_relative "llm_cost_tracker/parsers/gemini"
25
25
  require_relative "llm_cost_tracker/parsers/sse"
26
26
  require_relative "llm_cost_tracker/parsers/registry"
27
27
  require_relative "llm_cost_tracker/middleware/faraday"
28
+ require_relative "llm_cost_tracker/integrations/registry"
28
29
  require_relative "llm_cost_tracker/budget"
29
30
  require_relative "llm_cost_tracker/unknown_pricing"
30
31
  require_relative "llm_cost_tracker/event_metadata"
32
+ require_relative "llm_cost_tracker/tag_context"
31
33
  require_relative "llm_cost_tracker/tags_column"
32
34
  require_relative "llm_cost_tracker/tag_key"
33
35
  require_relative "llm_cost_tracker/tag_query"
@@ -37,6 +39,7 @@ require_relative "llm_cost_tracker/retention"
37
39
  require_relative "llm_cost_tracker/report_data"
38
40
  require_relative "llm_cost_tracker/report_formatter"
39
41
  require_relative "llm_cost_tracker/report"
42
+ require_relative "llm_cost_tracker/doctor"
40
43
 
41
44
  module LlmCostTracker
42
45
  CONFIGURATION_MUTEX = Monitor.new
@@ -52,10 +55,11 @@ module LlmCostTracker
52
55
  current = current.dup_for_configuration if current.finalized?
53
56
  @configuration = current
54
57
  yield(current)
55
- current.normalize_openai_compatible_providers!
58
+ current.openai_compatible_providers = current.openai_compatible_providers.dup
56
59
  current.finalize!
57
60
  current
58
61
  end
62
+ Integrations.install!
59
63
  warn_for_configuration!(config)
60
64
  end
61
65
 
@@ -63,14 +67,20 @@ module LlmCostTracker
63
67
  CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
64
68
  UnknownPricing.reset! if defined?(UnknownPricing)
65
69
  Storage::ActiveRecordStore.reset! if defined?(Storage::ActiveRecordStore)
70
+ TagContext.clear! if defined?(TagContext)
66
71
  end
67
72
 
68
73
  def enforce_budget!
69
74
  Tracker.enforce_budget!
70
75
  end
71
76
 
72
- def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false, usage_source: :manual,
73
- enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
77
+ def with_tags(tags = nil, **kwargs, &)
78
+ merged = (tags || {}).to_h.merge(kwargs)
79
+ TagContext.with(merged, &)
80
+ end
81
+
82
+ def track(provider:, input_tokens:, output_tokens:, model: nil, latency_ms: nil, stream: false,
83
+ usage_source: :manual, enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
74
84
  enforce_budget! if enforce_budget
75
85
  Tracker.record(
76
86
  provider: provider.to_s,
@@ -86,7 +96,7 @@ module LlmCostTracker
86
96
  )
87
97
  end
88
98
 
89
- def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
99
+ def track_stream(provider:, model: nil, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
90
100
  pricing_mode: nil, **metadata)
91
101
  require_relative "llm_cost_tracker/stream_collector"
92
102
  enforce_budget! if enforce_budget
@@ -1,7 +1,17 @@
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
7
+ desc "Check LLM Cost Tracker setup"
8
+ task :doctor do
9
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
10
+ checks = LlmCostTracker::Doctor.call
11
+ puts LlmCostTracker::Doctor.report(checks)
12
+ abort("llm_cost_tracker: doctor found setup errors") unless LlmCostTracker::Doctor.healthy?(checks)
13
+ end
14
+
5
15
  desc "Print an LLM cost report from ActiveRecord storage"
6
16
  task report: :environment do
7
17
  days = (ENV["DAYS"] || LlmCostTracker::Report::DEFAULT_DAYS).to_i
@@ -18,56 +28,49 @@ namespace :llm_cost_tracker do
18
28
 
19
29
  namespace :prices do
20
30
  desc(
21
- "Sync built-in pricing data from LiteLLM/OpenRouter JSON sources. " \
22
- "Use PREVIEW=1 to preview, STRICT=1 to fail on provider errors, " \
23
- "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."
24
33
  )
25
- task :sync do
34
+ task :refresh do
26
35
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
27
36
  require_relative "../llm_cost_tracker"
28
37
 
29
- output_path = ENV["OUTPUT"] || LlmCostTracker.configuration.prices_file || LlmCostTracker::PriceSync::DEFAULT_OUTPUT_PATH
30
- strict = ENV["STRICT"] == "1" || ARGV.include?("--strict")
31
- 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(
32
42
  path: output_path,
33
- preview: ENV["PREVIEW"] == "1",
34
- strict: strict
43
+ url: source_url,
44
+ preview: preview
35
45
  )
36
46
 
37
- action = if ENV["PREVIEW"] == "1"
47
+ action = if preview
38
48
  "previewed"
39
49
  elsif result.written
40
- "updated"
50
+ "refreshed"
41
51
  else
42
52
  "kept"
43
53
  end
44
54
 
45
55
  puts "llm_cost_tracker: #{action} pricing file #{result.path}"
46
- print_source_usage(result.sources_used)
56
+ puts " source: #{result.source_url}"
57
+ puts " version: #{result.source_version.inspect}" if result.source_version
47
58
  print_changes(result.changes)
48
- print_discrepancies(result.discrepancies)
49
- print_issues("validator rejected", result.rejected)
50
- print_issues("validator flagged", result.flagged)
51
- print_models("orphaned models (no JSON source match)", result.orphaned_models)
52
- print_failures(result.failed_sources, heading: "source failures (kept existing values)")
53
59
  end
54
60
 
55
- 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."
56
62
  task :check do
57
63
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
58
64
  require_relative "../llm_cost_tracker"
59
65
 
60
- output_path = ENV["OUTPUT"] || LlmCostTracker.configuration.prices_file || LlmCostTracker::PriceSync::DEFAULT_OUTPUT_PATH
61
- 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)
62
69
 
63
70
  puts "llm_cost_tracker: checked pricing file #{result.path}"
64
- print_source_usage(result.sources_used)
71
+ puts " source: #{result.source_url}"
72
+ puts " version: #{result.source_version.inspect}" if result.source_version
65
73
  print_changes(result.changes)
66
- print_discrepancies(result.discrepancies)
67
- print_issues("validator rejected", result.rejected)
68
- print_issues("validator flagged", result.flagged)
69
- print_models("orphaned models (no JSON source match)", result.orphaned_models)
70
- print_failures(result.failed_sources, heading: "source failures")
71
74
  puts " pricing is up to date" if result.up_to_date
72
75
  abort("llm_cost_tracker: pricing check failed") unless result.up_to_date
73
76
  end
@@ -75,16 +78,6 @@ namespace :llm_cost_tracker do
75
78
  end
76
79
  # rubocop:enable Metrics/BlockLength
77
80
 
78
- def print_source_usage(sources_used)
79
- return if sources_used.empty?
80
-
81
- puts " sources used:"
82
- sources_used.each do |source, usage|
83
- version = usage.source_version ? ", version=#{usage.source_version.inspect}" : ""
84
- puts " - #{source} (#{usage.prices_count} prices#{version})"
85
- end
86
- end
87
-
88
81
  def print_changes(changes)
89
82
  puts " changed models: #{changes.size}"
90
83
  return if changes.empty?
@@ -97,37 +90,8 @@ def print_changes(changes)
97
90
  end
98
91
  end
99
92
 
100
- def print_discrepancies(discrepancies)
101
- return if discrepancies.empty?
102
-
103
- puts " source discrepancies: #{discrepancies.size}"
104
- discrepancies.each do |issue|
105
- formatted = issue.values.map { |source, value| "#{source}=#{value.inspect}" }.join(", ")
106
- puts " - #{issue.model} #{issue.field}: #{formatted}"
107
- end
108
- end
109
-
110
- def print_issues(heading, issues)
111
- return if issues.empty?
112
-
113
- puts " #{heading}: #{issues.size}"
114
- issues.each do |issue|
115
- puts " - #{issue.model}: #{issue.reason}"
116
- end
117
- end
118
-
119
- def print_models(heading, models)
120
- return if models.empty?
121
-
122
- puts " #{heading}: #{models.size}"
123
- models.each { |model| puts " - #{model}" }
124
- end
125
-
126
- def print_failures(failed_sources, heading:)
127
- return if failed_sources.empty?
128
-
129
- puts " #{heading}: #{failed_sources.size}"
130
- failed_sources.each do |source, message|
131
- puts " - #{source}: #{message}"
132
- end
93
+ def price_refresh_output_path
94
+ path = LlmCostTracker::PriceSync.configured_output_path
95
+ FileUtils.mkdir_p(File.dirname(path))
96
+ path
133
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.4.1
4
+ version: 0.5.1
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-24 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
@@ -272,7 +286,9 @@ files:
272
286
  - lib/llm_cost_tracker/assets.rb
273
287
  - lib/llm_cost_tracker/budget.rb
274
288
  - lib/llm_cost_tracker/configuration.rb
289
+ - lib/llm_cost_tracker/configuration/instrumentation.rb
275
290
  - lib/llm_cost_tracker/cost.rb
291
+ - lib/llm_cost_tracker/doctor.rb
276
292
  - lib/llm_cost_tracker/engine.rb
277
293
  - lib/llm_cost_tracker/engine_compatibility.rb
278
294
  - lib/llm_cost_tracker/errors.rb
@@ -292,11 +308,15 @@ files:
292
308
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb
293
309
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
294
310
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
295
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
296
311
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb
297
312
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb
298
313
  - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb
299
314
  - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb
315
+ - lib/llm_cost_tracker/integrations/anthropic.rb
316
+ - lib/llm_cost_tracker/integrations/base.rb
317
+ - lib/llm_cost_tracker/integrations/object_reader.rb
318
+ - lib/llm_cost_tracker/integrations/openai.rb
319
+ - lib/llm_cost_tracker/integrations/registry.rb
300
320
  - lib/llm_cost_tracker/llm_api_call.rb
301
321
  - lib/llm_cost_tracker/logging.rb
302
322
  - lib/llm_cost_tracker/middleware/faraday.rb
@@ -312,31 +332,27 @@ files:
312
332
  - lib/llm_cost_tracker/parsers/sse.rb
313
333
  - lib/llm_cost_tracker/period_grouping.rb
314
334
  - lib/llm_cost_tracker/period_total.rb
335
+ - lib/llm_cost_tracker/price_freshness.rb
315
336
  - lib/llm_cost_tracker/price_registry.rb
316
337
  - lib/llm_cost_tracker/price_sync.rb
317
338
  - lib/llm_cost_tracker/price_sync/fetcher.rb
318
- - lib/llm_cost_tracker/price_sync/merger.rb
319
- - lib/llm_cost_tracker/price_sync/model_catalog.rb
320
- - lib/llm_cost_tracker/price_sync/raw_price.rb
321
- - lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb
339
+ - lib/llm_cost_tracker/price_sync/registry_diff.rb
322
340
  - lib/llm_cost_tracker/price_sync/registry_loader.rb
323
341
  - lib/llm_cost_tracker/price_sync/registry_writer.rb
324
- - lib/llm_cost_tracker/price_sync/source.rb
325
- - lib/llm_cost_tracker/price_sync/source_result.rb
326
- - lib/llm_cost_tracker/price_sync/sources/litellm.rb
327
- - lib/llm_cost_tracker/price_sync/sources/open_router.rb
328
- - lib/llm_cost_tracker/price_sync/validator.rb
329
342
  - lib/llm_cost_tracker/prices.json
330
343
  - lib/llm_cost_tracker/pricing.rb
331
344
  - lib/llm_cost_tracker/railtie.rb
332
345
  - lib/llm_cost_tracker/report.rb
333
346
  - lib/llm_cost_tracker/report_data.rb
334
347
  - lib/llm_cost_tracker/report_formatter.rb
348
+ - lib/llm_cost_tracker/request_url.rb
335
349
  - lib/llm_cost_tracker/retention.rb
336
350
  - lib/llm_cost_tracker/storage/active_record_rollups.rb
337
351
  - lib/llm_cost_tracker/storage/active_record_store.rb
352
+ - lib/llm_cost_tracker/storage/dispatcher.rb
338
353
  - lib/llm_cost_tracker/stream_collector.rb
339
354
  - lib/llm_cost_tracker/tag_accessors.rb
355
+ - lib/llm_cost_tracker/tag_context.rb
340
356
  - lib/llm_cost_tracker/tag_key.rb
341
357
  - lib/llm_cost_tracker/tag_query.rb
342
358
  - lib/llm_cost_tracker/tags_column.rb
@@ -1,51 +0,0 @@
1
- # Local LlmCostTracker price overrides.
2
- #
3
- # Add only the models you want to override or add. Built-in prices still come
4
- # from the gem's prices.json, and Ruby pricing_overrides still take precedence.
5
- #
6
- # Units: USD per 1M tokens.
7
- #
8
- # Supported price keys:
9
- # - input
10
- # - output
11
- # - cache_read_input
12
- # - cache_write_input
13
- # - mode_input / mode_output / mode_cache_read_input / mode_cache_write_input
14
- #
15
- # Optional metadata keys, ignored by cost calculation:
16
- # - _source
17
- # - _source_version
18
- # - _fetched_at
19
- # - _updated
20
- # - _notes
21
- # - _validator_override
22
- #
23
- # Example: custom fine-tune
24
- # models:
25
- # "ft:gpt-4o-mini:my-org":
26
- # input: 0.30
27
- # cache_read_input: 0.15
28
- # output: 1.20
29
- # _notes: "Internal fine-tune rate"
30
- #
31
- # Example: alternate pricing mode
32
- # models:
33
- # "batchable-model":
34
- # input: 1.00
35
- # output: 2.00
36
- # batch_input: 0.50
37
- # batch_output: 1.00
38
- #
39
- # Example: negotiated provider discount
40
- # models:
41
- # "gpt-4o":
42
- # input: 2.00
43
- # output: 8.00
44
- # _source: "manual"
45
- # _updated: "2026-04-18"
46
- #
47
- # Use _source: "manual" for custom or orphaned entries you never want sync to touch.
48
- # Use _validator_override: ["skip_relative_change"] if a negotiated price would
49
- # otherwise trip the >3x sync warning.
50
-
51
- models:
@@ -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