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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +112 -467
- data/lib/llm_cost_tracker/doctor.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -2
- data/lib/llm_cost_tracker/price_freshness.rb +3 -3
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +3 -1
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
- data/lib/llm_cost_tracker/price_sync.rb +103 -111
- data/lib/llm_cost_tracker/prices.json +225 -229
- data/lib/llm_cost_tracker/pricing.rb +27 -15
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
- data/lib/llm_cost_tracker/tracker.rb +2 -58
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/tasks/llm_cost_tracker.rake +24 -78
- metadata +18 -11
- data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
- data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
- data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
- data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
PriceRegistry.metadata
|
|
53
|
-
end
|
|
46
|
+
private
|
|
54
47
|
|
|
55
|
-
def
|
|
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 =
|
|
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
|
-
|
|
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 "
|
|
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 =
|
|
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,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
|
-
"
|
|
30
|
-
"Use PREVIEW=1 to preview,
|
|
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 :
|
|
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 =
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
+
url: source_url,
|
|
44
|
+
preview: preview
|
|
43
45
|
)
|
|
44
46
|
|
|
45
|
-
action = if
|
|
47
|
+
action = if preview
|
|
46
48
|
"previewed"
|
|
47
49
|
elsif result.written
|
|
48
|
-
"
|
|
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
|
-
|
|
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
|
|
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 =
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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/
|
|
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
|