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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +116 -467
- data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
- data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
- data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
- data/lib/llm_cost_tracker/configuration.rb +22 -16
- data/lib/llm_cost_tracker/doctor.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
- data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
- data/lib/llm_cost_tracker/integrations/base.rb +77 -6
- data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
- data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
- data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
- data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
- data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
- data/lib/llm_cost_tracker/price_freshness.rb +3 -3
- data/lib/llm_cost_tracker/price_registry.rb +3 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -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/report.rb +8 -1
- data/lib/llm_cost_tracker/report_data.rb +25 -9
- data/lib/llm_cost_tracker/retention.rb +30 -7
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
- data/lib/llm_cost_tracker/stream_capture.rb +7 -0
- data/lib/llm_cost_tracker/stream_collector.rb +25 -1
- data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
- data/lib/llm_cost_tracker/tracker.rb +7 -59
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- data/lib/tasks/llm_cost_tracker.rake +24 -78
- metadata +26 -15
- 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
|
@@ -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.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-
|
|
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
|
|
212
|
-
Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works
|
|
213
|
-
middleware or explicit track/track_stream helpers, with ActiveRecord
|
|
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/
|
|
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
|