llm_cost_tracker 0.2.0 → 0.3.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 +36 -0
- data/README.md +124 -68
- data/Rakefile +2 -0
- data/app/assets/llm_cost_tracker/application.css +1 -4
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
- data/app/services/llm_cost_tracker/pagination.rb +1 -9
- data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
- data/app/views/llm_cost_tracker/calls/index.html.erb +23 -13
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +11 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
- data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/assets.rb +6 -11
- data/lib/llm_cost_tracker/configuration.rb +78 -43
- data/lib/llm_cost_tracker/event.rb +3 -0
- data/lib/llm_cost_tracker/event_metadata.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
- data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
- data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
- data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
- data/lib/llm_cost_tracker/parsers/base.rb +17 -5
- data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
- data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
- data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
- data/lib/llm_cost_tracker/price_registry.rb +23 -8
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
- data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
- data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
- data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
- data/lib/llm_cost_tracker/price_sync.rb +142 -0
- data/lib/llm_cost_tracker/pricing.rb +0 -11
- data/lib/llm_cost_tracker/railtie.rb +0 -1
- data/lib/llm_cost_tracker/report.rb +0 -5
- data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
- data/lib/llm_cost_tracker/stream_collector.rb +162 -0
- data/lib/llm_cost_tracker/tags_column.rb +12 -0
- data/lib/llm_cost_tracker/tracker.rb +23 -12
- data/lib/llm_cost_tracker/value_helpers.rb +40 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +48 -35
- data/lib/tasks/llm_cost_tracker.rake +116 -0
- data/llm_cost_tracker.gemspec +8 -6
- metadata +30 -8
|
@@ -0,0 +1,77 @@
|
|
|
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
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
:cached_input,
|
|
11
|
+
:cache_read_input,
|
|
12
|
+
:cache_creation_input,
|
|
13
|
+
:source,
|
|
14
|
+
:source_version,
|
|
15
|
+
:fetched_at
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class RawPrice
|
|
19
|
+
PRICE_FIELDS = %w[input output cached_input cache_read_input cache_creation_input].freeze
|
|
20
|
+
|
|
21
|
+
def to_registry_entry(today:)
|
|
22
|
+
{
|
|
23
|
+
"input" => input,
|
|
24
|
+
"output" => output,
|
|
25
|
+
"cached_input" => cached_input,
|
|
26
|
+
"cache_read_input" => cache_read_input,
|
|
27
|
+
"cache_creation_input" => cache_creation_input,
|
|
28
|
+
"_source" => source.to_s,
|
|
29
|
+
"_source_version" => source_version,
|
|
30
|
+
"_fetched_at" => fetched_at || today.iso8601
|
|
31
|
+
}.compact
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
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),
|
|
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)
|
|
74
|
+
seed_models(current_models).keys.reject do |model|
|
|
75
|
+
manual_model?(current_models[model]) || merged_models.include?(model)
|
|
76
|
+
end.sort
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def seed_models(current_models)
|
|
80
|
+
normalize_models(current_models).transform_values do |entry|
|
|
81
|
+
next entry if entry.key?("_source")
|
|
82
|
+
|
|
83
|
+
entry.merge("_source" => "seed")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_models(models)
|
|
88
|
+
normalize_hash(models).each_with_object({}) do |(model, entry), normalized|
|
|
89
|
+
normalized[model.to_s] = normalize_hash(entry)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_hash(hash)
|
|
94
|
+
return {} if hash.nil?
|
|
95
|
+
raise ArgumentError, "price sync entries must be hashes" unless hash.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
98
|
+
normalized[key.to_s] = value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def manual_model?(entry)
|
|
103
|
+
normalize_hash(entry)["_source"] == "manual"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def registry_entry_for(existing_entry, price, today)
|
|
107
|
+
normalize_hash(existing_entry)
|
|
108
|
+
.except(*PriceRegistry::PRICE_KEYS)
|
|
109
|
+
.merge(price.to_registry_entry(today: today))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def updated_metadata(existing, today, refresh_succeeded:, source_results:)
|
|
113
|
+
metadata = normalize_hash(existing)
|
|
114
|
+
metadata["currency"] ||= "USD"
|
|
115
|
+
metadata["unit"] ||= "1M tokens"
|
|
116
|
+
return metadata unless refresh_succeeded
|
|
117
|
+
|
|
118
|
+
metadata["updated_at"] = today.iso8601
|
|
119
|
+
metadata["source_urls"] = source_urls(source_results)
|
|
120
|
+
metadata
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def source_usage(source_results)
|
|
124
|
+
source_results.transform_values do |result|
|
|
125
|
+
PriceSync::SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def price_changes(current_models, updated_models)
|
|
130
|
+
current_models = normalize_models(current_models)
|
|
131
|
+
updated_models = normalize_models(updated_models)
|
|
132
|
+
|
|
133
|
+
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
134
|
+
fields = price_field_changes(current_models[model], updated_models[model])
|
|
135
|
+
changes[model] = fields if fields.any?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def price_field_changes(current_entry, updated_entry)
|
|
140
|
+
current_price = comparable_price(current_entry)
|
|
141
|
+
updated_price = comparable_price(updated_entry)
|
|
142
|
+
|
|
143
|
+
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
144
|
+
from = current_price[field]
|
|
145
|
+
to = updated_price[field]
|
|
146
|
+
next if from == to
|
|
147
|
+
|
|
148
|
+
changes[field] = { "from" => from, "to" => to }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def comparable_price(entry)
|
|
153
|
+
normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def source_urls(source_results)
|
|
157
|
+
names = source_results.keys.map(&:to_sym)
|
|
158
|
+
sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module PriceSync
|
|
8
|
+
class RegistryLoader
|
|
9
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
10
|
+
|
|
11
|
+
def call(path:, seed_path:)
|
|
12
|
+
source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
|
|
13
|
+
normalize_registry(load_registry_file(source_path))
|
|
14
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
15
|
+
raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def load_registry_file(path)
|
|
21
|
+
contents = File.read(path)
|
|
22
|
+
registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
|
|
23
|
+
raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
registry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def normalize_registry(registry)
|
|
29
|
+
{
|
|
30
|
+
"metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
|
|
31
|
+
"models" => normalize_models(registry.fetch("models", {}))
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_models(models)
|
|
36
|
+
normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
|
|
37
|
+
normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_hash(hash, label:)
|
|
42
|
+
return {} if hash.nil?
|
|
43
|
+
raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
46
|
+
normalized[key.to_s] = value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def yaml_file?(path)
|
|
51
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module PriceSync
|
|
9
|
+
class RegistryWriter
|
|
10
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
11
|
+
|
|
12
|
+
def call(path:, registry:)
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
14
|
+
payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
|
|
15
|
+
File.write(path, payload)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def yaml_file?(path)
|
|
21
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module PriceSync
|
|
7
|
+
module Sources
|
|
8
|
+
class Litellm < Source
|
|
9
|
+
PER_TOKEN_TO_PER_MILLION = 1_000_000
|
|
10
|
+
SUPPORTED_MODES = %w[chat completion embedding responses].freeze
|
|
11
|
+
SUPPORTED_PROVIDERS = %w[openai anthropic gemini text-completion-openai].freeze
|
|
12
|
+
URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
|
|
13
|
+
|
|
14
|
+
def priority
|
|
15
|
+
10
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def url
|
|
19
|
+
URL
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(current_models:, fetcher:)
|
|
23
|
+
response = fetcher.get(url)
|
|
24
|
+
payload = JSON.parse(response.body.to_s)
|
|
25
|
+
|
|
26
|
+
prices = []
|
|
27
|
+
missing_models = []
|
|
28
|
+
|
|
29
|
+
current_models.each_key do |our_model|
|
|
30
|
+
entry_id = ModelCatalog.resolve_from_litellm(our_model, payload)
|
|
31
|
+
entry = entry_id && payload[entry_id]
|
|
32
|
+
|
|
33
|
+
if entry && supported_entry?(entry)
|
|
34
|
+
prices << build_raw_price(our_model, entry, response)
|
|
35
|
+
else
|
|
36
|
+
missing_models << our_model
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
SourceResult.new(
|
|
41
|
+
prices: prices,
|
|
42
|
+
missing_models: missing_models.sort,
|
|
43
|
+
source_version: response_version(response)
|
|
44
|
+
)
|
|
45
|
+
rescue JSON::ParserError => e
|
|
46
|
+
raise Error, "Unable to parse #{url}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def supported_entry?(entry)
|
|
52
|
+
SUPPORTED_PROVIDERS.include?(entry["litellm_provider"]) &&
|
|
53
|
+
SUPPORTED_MODES.include?(entry["mode"]) &&
|
|
54
|
+
entry.key?("input_cost_per_token") &&
|
|
55
|
+
entry.key?("output_cost_per_token")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_raw_price(model, entry, response)
|
|
59
|
+
provider = normalize_provider(entry["litellm_provider"])
|
|
60
|
+
cache_read = price_per_million(entry["cache_read_input_token_cost"])
|
|
61
|
+
cache_write = price_per_million(entry["cache_creation_input_token_cost"])
|
|
62
|
+
|
|
63
|
+
RawPrice.new(
|
|
64
|
+
model: model,
|
|
65
|
+
provider: provider,
|
|
66
|
+
input: price_per_million(entry["input_cost_per_token"]),
|
|
67
|
+
output: price_per_million(entry["output_cost_per_token"]),
|
|
68
|
+
cached_input: provider == "anthropic" ? nil : cache_read,
|
|
69
|
+
cache_read_input: provider == "anthropic" ? cache_read : nil,
|
|
70
|
+
cache_creation_input: provider == "anthropic" ? cache_write : nil,
|
|
71
|
+
source: name,
|
|
72
|
+
source_version: response_version(response),
|
|
73
|
+
fetched_at: response.fetched_at
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_provider(provider)
|
|
78
|
+
return "openai" if provider == "text-completion-openai"
|
|
79
|
+
|
|
80
|
+
provider
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def price_per_million(value)
|
|
84
|
+
return nil if value.nil?
|
|
85
|
+
|
|
86
|
+
value.to_f * PER_TOKEN_TO_PER_MILLION
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module PriceSync
|
|
7
|
+
module Sources
|
|
8
|
+
class OpenRouter < Source
|
|
9
|
+
PER_TOKEN_TO_PER_MILLION = 1_000_000
|
|
10
|
+
SUPPORTED_PREFIXES = %w[openai anthropic google].freeze
|
|
11
|
+
URL = "https://openrouter.ai/api/v1/models"
|
|
12
|
+
|
|
13
|
+
def priority
|
|
14
|
+
20
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def url
|
|
18
|
+
URL
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(current_models:, fetcher:)
|
|
22
|
+
response = fetcher.get(url)
|
|
23
|
+
payload = JSON.parse(response.body.to_s)
|
|
24
|
+
index = payload.fetch("data", []).to_h { |entry| [entry["id"], entry] }
|
|
25
|
+
|
|
26
|
+
prices = []
|
|
27
|
+
missing_models = []
|
|
28
|
+
|
|
29
|
+
current_models.each_key do |our_model|
|
|
30
|
+
entry_id = ModelCatalog.resolve_from_openrouter(our_model, index)
|
|
31
|
+
entry = entry_id && index[entry_id]
|
|
32
|
+
|
|
33
|
+
if entry && supported_entry?(entry)
|
|
34
|
+
prices << build_raw_price(our_model, entry, response)
|
|
35
|
+
else
|
|
36
|
+
missing_models << our_model
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
SourceResult.new(
|
|
41
|
+
prices: prices,
|
|
42
|
+
missing_models: missing_models.sort,
|
|
43
|
+
source_version: response_version(response)
|
|
44
|
+
)
|
|
45
|
+
rescue JSON::ParserError => e
|
|
46
|
+
raise Error, "Unable to parse #{url}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def supported_entry?(entry)
|
|
52
|
+
pricing = entry["pricing"] || {}
|
|
53
|
+
provider = entry["id"].to_s.split("/").first
|
|
54
|
+
|
|
55
|
+
SUPPORTED_PREFIXES.include?(provider) &&
|
|
56
|
+
pricing["prompt"] &&
|
|
57
|
+
pricing["completion"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_raw_price(model, entry, response)
|
|
61
|
+
pricing = entry.fetch("pricing", {})
|
|
62
|
+
provider = normalize_provider(entry.fetch("id").split("/").first)
|
|
63
|
+
cache_read = price_per_million(pricing["input_cache_read"])
|
|
64
|
+
cache_write = price_per_million(pricing["input_cache_write"])
|
|
65
|
+
|
|
66
|
+
RawPrice.new(
|
|
67
|
+
model: model,
|
|
68
|
+
provider: provider,
|
|
69
|
+
input: price_per_million(pricing["prompt"]),
|
|
70
|
+
output: price_per_million(pricing["completion"]),
|
|
71
|
+
cached_input: provider == "anthropic" ? nil : cache_read,
|
|
72
|
+
cache_read_input: provider == "anthropic" ? cache_read : nil,
|
|
73
|
+
cache_creation_input: provider == "anthropic" ? cache_write : nil,
|
|
74
|
+
source: name,
|
|
75
|
+
source_version: response_version(response),
|
|
76
|
+
fetched_at: response.fetched_at
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize_provider(provider)
|
|
81
|
+
return "gemini" if provider == "google"
|
|
82
|
+
|
|
83
|
+
provider
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def price_per_million(value)
|
|
87
|
+
return nil if value.nil?
|
|
88
|
+
|
|
89
|
+
value.to_f * PER_TOKEN_TO_PER_MILLION
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
class Validator
|
|
6
|
+
Result = Data.define(:accepted, :rejected, :flagged)
|
|
7
|
+
Issue = Data.define(:model, :reason, :old_price, :new_price)
|
|
8
|
+
|
|
9
|
+
MAX_INPUT_PER_MILLION = 100.0
|
|
10
|
+
MAX_OUTPUT_PER_MILLION = 500.0
|
|
11
|
+
MAX_RELATIVE_CHANGE = 3.0
|
|
12
|
+
|
|
13
|
+
def validate_batch(merged_prices, existing_registry:)
|
|
14
|
+
merged_prices.each_with_object(Result.new(accepted: {}, rejected: [], flagged: [])) do |(model, price), result|
|
|
15
|
+
old_price = normalize_entry(existing_registry[model])
|
|
16
|
+
status, reason = validate(new_price: price, old_price: old_price)
|
|
17
|
+
|
|
18
|
+
case status
|
|
19
|
+
when :rejected
|
|
20
|
+
result.rejected << Issue.new(model: model, reason: reason, old_price: old_price, new_price: price)
|
|
21
|
+
when :flagged
|
|
22
|
+
result.flagged << Issue.new(model: model, reason: reason, old_price: old_price, new_price: price)
|
|
23
|
+
result.accepted[model] = price
|
|
24
|
+
else
|
|
25
|
+
result.accepted[model] = price
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def validate(new_price:, old_price:)
|
|
33
|
+
overrides = Array(normalize_entry(old_price)["_validator_override"])
|
|
34
|
+
|
|
35
|
+
return [:rejected, "input > $#{MAX_INPUT_PER_MILLION}/1M"] if new_price.input > MAX_INPUT_PER_MILLION
|
|
36
|
+
return [:rejected, "output > $#{MAX_OUTPUT_PER_MILLION}/1M"] if new_price.output > MAX_OUTPUT_PER_MILLION
|
|
37
|
+
return [:ok, nil] if overrides.include?("skip_relative_change")
|
|
38
|
+
|
|
39
|
+
if old_price.any? && changed_too_much?(old_price, new_price)
|
|
40
|
+
return [:flagged, "price changed >#{MAX_RELATIVE_CHANGE}x"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[:ok, nil]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def changed_too_much?(old_price, new_price)
|
|
47
|
+
%i[input output].any? do |field|
|
|
48
|
+
old_value = old_price[field.to_s].to_f
|
|
49
|
+
next false if old_value.zero?
|
|
50
|
+
|
|
51
|
+
new_value = new_price.public_send(field).to_f
|
|
52
|
+
next false if new_value.zero?
|
|
53
|
+
|
|
54
|
+
ratio = [new_value / old_value, old_value / new_value].max
|
|
55
|
+
ratio > MAX_RELATIVE_CHANGE
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_entry(entry)
|
|
60
|
+
(entry || {}).each_with_object({}) do |(key, value), normalized|
|
|
61
|
+
normalized[key.to_s] = value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|