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.
@@ -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
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module PriceSync
5
- SourceResult = Data.define(:prices, :missing_models, :source_version)
6
- end
7
- end
@@ -1,90 +0,0 @@
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
- cache_read_input: cache_read,
69
- cache_write_input: cache_write,
70
- source: name,
71
- source_version: response_version(response),
72
- fetched_at: response.fetched_at
73
- )
74
- end
75
-
76
- def normalize_provider(provider)
77
- return "openai" if provider == "text-completion-openai"
78
-
79
- provider
80
- end
81
-
82
- def price_per_million(value)
83
- return nil if value.nil?
84
-
85
- value.to_f * PER_TOKEN_TO_PER_MILLION
86
- end
87
- end
88
- end
89
- end
90
- end
@@ -1,93 +0,0 @@
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
- cache_read_input: cache_read,
72
- cache_write_input: cache_write,
73
- source: name,
74
- source_version: response_version(response),
75
- fetched_at: response.fetched_at
76
- )
77
- end
78
-
79
- def normalize_provider(provider)
80
- return "gemini" if provider == "google"
81
-
82
- provider
83
- end
84
-
85
- def price_per_million(value)
86
- return nil if value.nil?
87
-
88
- value.to_f * PER_TOKEN_TO_PER_MILLION
89
- end
90
- end
91
- end
92
- end
93
- end
@@ -1,66 +0,0 @@
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