llm_cost_tracker 0.5.0 → 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.
@@ -32,27 +32,20 @@ module LlmCostTracker
32
32
  end
33
33
 
34
34
  def lookup(provider:, model:)
35
- table = prices
36
35
  provider_name = provider.to_s
37
36
  model_name = model.to_s
38
37
  provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
39
38
  normalized_model = normalize_model_name(model_name)
39
+ current = current_price_tables
40
40
 
41
- table[provider_model] ||
42
- table[model_name] ||
43
- table[normalized_model] ||
44
- fuzzy_match(provider_model, normalized_model, table)
45
- end
46
-
47
- def models
48
- prices.keys
41
+ lookup_in_table(current.fetch(:pricing_overrides), provider_model, model_name, normalized_model) ||
42
+ lookup_in_table(current.fetch(:file_prices), provider_model, model_name, normalized_model) ||
43
+ lookup_in_table(PRICES, provider_model, model_name, normalized_model)
49
44
  end
50
45
 
51
- def metadata
52
- PriceRegistry.metadata
53
- end
46
+ private
54
47
 
55
- def prices
48
+ def current_price_tables
56
49
  file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
57
50
  overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
58
51
  cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
@@ -64,13 +57,22 @@ module LlmCostTracker
64
57
  cached = @prices_cache
65
58
  return cached[:value] if cached && cached[:key] == cache_key
66
59
 
67
- value = PRICES.merge(file_prices).merge(overrides).freeze
60
+ value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
68
61
  @prices_cache = { key: cache_key, value: value }.freeze
69
62
  value
70
63
  end
71
64
  end
72
65
 
73
- private
66
+ def lookup_in_table(table, provider_model, model_name, normalized_model)
67
+ return nil if table.empty?
68
+
69
+ table[provider_model] ||
70
+ table[model_name] ||
71
+ table[normalized_model] ||
72
+ unique_providerless_lookup(normalized_model, table) ||
73
+ fuzzy_match(provider_model, normalized_model, table) ||
74
+ unique_providerless_fuzzy_match(normalized_model, table)
75
+ end
74
76
 
75
77
  def calculate_costs(usage, prices, pricing_mode:)
76
78
  {
@@ -113,6 +115,11 @@ module LlmCostTracker
113
115
  model.to_s.split("/").last
114
116
  end
115
117
 
118
+ def unique_providerless_lookup(model, table)
119
+ matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
120
+ table[matches.first] if matches.one?
121
+ end
122
+
116
123
  def fuzzy_match(model, normalized_model, table)
117
124
  sorted_price_keys(table).each do |key|
118
125
  return table[key] if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
@@ -121,6 +128,11 @@ module LlmCostTracker
121
128
  nil
122
129
  end
123
130
 
131
+ def unique_providerless_fuzzy_match(model, table)
132
+ matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
133
+ table[matches.first] if matches.one?
134
+ end
135
+
124
136
  def snapshot_variant?(model, key)
125
137
  suffix = model.delete_prefix("#{key}-")
126
138
  return false if suffix == model
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ class Dispatcher
8
+ class << self
9
+ def save(event)
10
+ config = LlmCostTracker.configuration
11
+ case config.storage_backend
12
+ when :log then log_event(event, config)
13
+ when :active_record then active_record_save(event)
14
+ when :custom then custom_save(event, config)
15
+ end
16
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
17
+ raise
18
+ rescue StandardError => e
19
+ handle_error(e)
20
+ false
21
+ end
22
+
23
+ private
24
+
25
+ def log_event(event, config)
26
+ message = "#{event.provider}/#{event.model} " \
27
+ "tokens=#{event.total_tokens} " \
28
+ "cost=#{log_cost_label(event)}"
29
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
30
+ message += " stream=#{event.stream}" if event.stream
31
+ message += " source=#{event.usage_source}" if event.usage_source
32
+ message += " tags=#{event.tags}" unless event.tags.empty?
33
+
34
+ Logging.log(config.log_level, message)
35
+ event
36
+ end
37
+
38
+ def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
39
+
40
+ def active_record_save(event)
41
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
42
+ require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
43
+
44
+ ActiveRecordStore.save(event)
45
+ event
46
+ rescue LoadError => e
47
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
48
+ end
49
+
50
+ def custom_save(event, config)
51
+ result = config.custom_storage&.call(event)
52
+ result == false ? false : event
53
+ end
54
+
55
+ def handle_error(error)
56
+ case LlmCostTracker.configuration.storage_error_behavior
57
+ when :ignore
58
+ nil
59
+ when :warn
60
+ Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
61
+ when :raise
62
+ raise StorageError, error
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,6 +1,6 @@
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
@@ -39,7 +39,7 @@ module LlmCostTracker
39
39
 
40
40
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
41
41
 
42
- stored = store(event)
42
+ stored = Storage::Dispatcher.save(event)
43
43
  Budget.check!(event) unless stored == false
44
44
 
45
45
  event
@@ -93,62 +93,6 @@ module LlmCostTracker
93
93
  )
94
94
  end
95
95
 
96
- def store(event)
97
- config = LlmCostTracker.configuration
98
- case config.storage_backend
99
- when :log then log_event(event, config)
100
- when :active_record then active_record_save(event)
101
- when :custom then custom_save(event, config)
102
- end
103
- rescue BudgetExceededError, UnknownPricingError
104
- raise
105
- rescue StandardError => e
106
- handle_storage_error(e)
107
- false
108
- end
109
-
110
- def log_event(event, config)
111
- message = "#{event.provider}/#{event.model} " \
112
- "tokens=#{event.total_tokens} " \
113
- "cost=#{log_cost_label(event)}"
114
- message += " latency=#{event.latency_ms}ms" if event.latency_ms
115
- message += " stream=#{event.stream}" if event.stream
116
- message += " source=#{event.usage_source}" if event.usage_source
117
- message += " tags=#{event.tags}" unless event.tags.empty?
118
-
119
- Logging.log(config.log_level, message)
120
- event
121
- end
122
-
123
- def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
124
-
125
- def active_record_save(event)
126
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
127
- require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
128
-
129
- Storage::ActiveRecordStore.save(event)
130
- event
131
- rescue LoadError => e
132
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
133
- end
134
-
135
- def custom_save(event, config)
136
- result = config.custom_storage&.call(event)
137
- result == false ? false : event
138
- end
139
-
140
- def handle_storage_error(error)
141
- case LlmCostTracker.configuration.storage_error_behavior
142
- when :ignore
143
- nil
144
- when :warn
145
- Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
146
- when :raise
147
- storage_error = StorageError.new(error)
148
- raise storage_error
149
- end
150
- end
151
-
152
96
  def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
153
97
 
154
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.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -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.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-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
@@ -322,17 +336,9 @@ files:
322
336
  - lib/llm_cost_tracker/price_registry.rb
323
337
  - lib/llm_cost_tracker/price_sync.rb
324
338
  - 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
339
+ - lib/llm_cost_tracker/price_sync/registry_diff.rb
329
340
  - lib/llm_cost_tracker/price_sync/registry_loader.rb
330
341
  - 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
342
  - lib/llm_cost_tracker/prices.json
337
343
  - lib/llm_cost_tracker/pricing.rb
338
344
  - lib/llm_cost_tracker/railtie.rb
@@ -343,6 +349,7 @@ files:
343
349
  - lib/llm_cost_tracker/retention.rb
344
350
  - lib/llm_cost_tracker/storage/active_record_rollups.rb
345
351
  - lib/llm_cost_tracker/storage/active_record_store.rb
352
+ - lib/llm_cost_tracker/storage/dispatcher.rb
346
353
  - lib/llm_cost_tracker/stream_collector.rb
347
354
  - lib/llm_cost_tracker/tag_accessors.rb
348
355
  - lib/llm_cost_tracker/tag_context.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