llm_cost_tracker 0.14.2 → 0.15.0
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 +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
-
require "bigdecimal/util"
|
|
5
4
|
require "yaml"
|
|
6
5
|
|
|
7
6
|
require_relative "../usage/catalog"
|
|
8
|
-
require_relative "../pricing/rate"
|
|
9
7
|
require_relative "../logging"
|
|
10
|
-
require_relative "mode"
|
|
11
|
-
require_relative "price_key"
|
|
12
8
|
require_relative "source"
|
|
9
|
+
require_relative "registry/normalizer"
|
|
13
10
|
|
|
14
11
|
module LlmCostTracker
|
|
15
12
|
module Pricing
|
|
16
13
|
module Registry
|
|
17
14
|
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
18
15
|
CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
|
|
16
|
+
OFF_PEAK_WINDOWS_KEY = "_off_peak_windows"
|
|
17
|
+
MINIMUM_BILLED_SECONDS_KEY = "_minimum_billed_seconds"
|
|
19
18
|
PRICE_KEYS = Usage::Catalog.all.select(&:token?).map(&:key).freeze
|
|
20
|
-
METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
|
|
19
|
+
METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY, OFF_PEAK_WINDOWS_KEY, MINIMUM_BILLED_SECONDS_KEY].freeze
|
|
21
20
|
|
|
22
21
|
class << self
|
|
23
22
|
def reset!
|
|
@@ -66,13 +65,7 @@ module LlmCostTracker
|
|
|
66
65
|
end
|
|
67
66
|
|
|
68
67
|
def normalize_price_entries(table, context:)
|
|
69
|
-
table
|
|
70
|
-
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
71
|
-
|
|
72
|
-
table.each_with_object({}) do |(model, price), normalized|
|
|
73
|
-
price = validate_price_entry(price, model: model, context: context)
|
|
74
|
-
normalized[model.to_s] = normalize_price_entry(model, price, context)
|
|
75
|
-
end
|
|
68
|
+
Normalizer.new(context).prices(table)
|
|
76
69
|
end
|
|
77
70
|
|
|
78
71
|
def raw_registry
|
|
@@ -96,14 +89,7 @@ module LlmCostTracker
|
|
|
96
89
|
end
|
|
97
90
|
|
|
98
91
|
def rates_from_registry(registry, context:)
|
|
99
|
-
|
|
100
|
-
raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
|
|
101
|
-
|
|
102
|
-
currency = upcased_currency(registry.dig("metadata", "currency"))
|
|
103
|
-
data.each_with_object({}) do |(provider, entries), rates|
|
|
104
|
-
section_context = "#{context} service_charges.#{provider}"
|
|
105
|
-
rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
|
|
106
|
-
end
|
|
92
|
+
Normalizer.new(context).rates(registry)
|
|
107
93
|
end
|
|
108
94
|
|
|
109
95
|
def prices_file_mtime_iso
|
|
@@ -114,32 +100,7 @@ module LlmCostTracker
|
|
|
114
100
|
end
|
|
115
101
|
|
|
116
102
|
def sources
|
|
117
|
-
@sources ||=
|
|
118
|
-
config = LlmCostTracker.configuration
|
|
119
|
-
[
|
|
120
|
-
Source.new(
|
|
121
|
-
name: "pricing_overrides",
|
|
122
|
-
prices: config.pricing.overrides,
|
|
123
|
-
rates: {},
|
|
124
|
-
currency: upcased_currency(nil),
|
|
125
|
-
version: "configuration"
|
|
126
|
-
),
|
|
127
|
-
Source.new(
|
|
128
|
-
name: "prices_file",
|
|
129
|
-
prices: file_prices(config.pricing.file),
|
|
130
|
-
rates: file_rates(config.pricing.file),
|
|
131
|
-
currency: upcased_currency(file_metadata(config.pricing.file)["currency"]),
|
|
132
|
-
version: prices_file_mtime_iso
|
|
133
|
-
),
|
|
134
|
-
Source.new(
|
|
135
|
-
name: "bundled",
|
|
136
|
-
prices: builtin_prices,
|
|
137
|
-
rates: builtin_rates,
|
|
138
|
-
currency: upcased_currency(metadata["currency"]),
|
|
139
|
-
version: LlmCostTracker::VERSION
|
|
140
|
-
)
|
|
141
|
-
].freeze
|
|
142
|
-
end
|
|
103
|
+
@sources ||= [overrides_source, file_source, bundled_source].freeze
|
|
143
104
|
end
|
|
144
105
|
|
|
145
106
|
def sorted_price_keys(table)
|
|
@@ -150,6 +111,37 @@ module LlmCostTracker
|
|
|
150
111
|
|
|
151
112
|
private
|
|
152
113
|
|
|
114
|
+
def overrides_source
|
|
115
|
+
Source.new(
|
|
116
|
+
name: "pricing_overrides",
|
|
117
|
+
prices: LlmCostTracker.configuration.pricing.overrides,
|
|
118
|
+
rates: {},
|
|
119
|
+
currency: upcased_currency(nil),
|
|
120
|
+
version: "configuration"
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def file_source
|
|
125
|
+
path = LlmCostTracker.configuration.pricing.file
|
|
126
|
+
Source.new(
|
|
127
|
+
name: "prices_file",
|
|
128
|
+
prices: file_prices(path),
|
|
129
|
+
rates: file_rates(path),
|
|
130
|
+
currency: upcased_currency(file_metadata(path)["currency"]),
|
|
131
|
+
version: prices_file_mtime_iso
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def bundled_source
|
|
136
|
+
Source.new(
|
|
137
|
+
name: "bundled",
|
|
138
|
+
prices: builtin_prices,
|
|
139
|
+
rates: builtin_rates,
|
|
140
|
+
currency: upcased_currency(metadata["currency"]),
|
|
141
|
+
version: LlmCostTracker::VERSION
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
153
145
|
def memoize_in(cache, key, identity: false)
|
|
154
146
|
existing = cache && cache[key]
|
|
155
147
|
return [existing, cache] if existing
|
|
@@ -182,75 +174,10 @@ module LlmCostTracker
|
|
|
182
174
|
end
|
|
183
175
|
end
|
|
184
176
|
|
|
185
|
-
def normalize_price_entry(model, price, context)
|
|
186
|
-
unknown = []
|
|
187
|
-
normalized = price.each_with_object({}) do |(key, value), acc|
|
|
188
|
-
if key.to_s == CONTEXT_THRESHOLD_KEY
|
|
189
|
-
acc[CONTEXT_THRESHOLD_KEY] = Integer(value)
|
|
190
|
-
elsif (registry_key = PriceKey.price_key_for(key))
|
|
191
|
-
acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
|
|
192
|
-
elsif !METADATA_KEYS.include?(key)
|
|
193
|
-
unknown << key
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
warn_unknown_keys(model, unknown, context) unless unknown.empty?
|
|
197
|
-
normalized
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def non_negative_decimal(value, label:)
|
|
201
|
-
decimal = BigDecimal(value.to_s)
|
|
202
|
-
raise ArgumentError, "#{label} must be finite (got #{value})" unless decimal.finite?
|
|
203
|
-
raise ArgumentError, "#{label} must be non-negative (got #{value})" if decimal.negative?
|
|
204
|
-
|
|
205
|
-
decimal
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def warn_unknown_keys(model, unknown_keys, path)
|
|
209
|
-
Logging.warn(
|
|
210
|
-
"Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
|
|
211
|
-
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
|
|
212
|
-
)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def validate_price_entry(price, model:, context:)
|
|
216
|
-
return {} if price.nil?
|
|
217
|
-
return price if price.is_a?(Hash)
|
|
218
|
-
|
|
219
|
-
raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
|
|
220
|
-
end
|
|
221
|
-
|
|
222
177
|
def load_file_rates(path)
|
|
223
178
|
loading(path) { rates_from_registry(raw_file_registry(path), context: path).freeze }
|
|
224
179
|
end
|
|
225
180
|
|
|
226
|
-
def rates_from_section(entries, currency:, context:)
|
|
227
|
-
raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
|
|
228
|
-
|
|
229
|
-
entries.each_with_object({}) do |(key, amount), rates|
|
|
230
|
-
key = key.to_s
|
|
231
|
-
dimension, tier = dimension_and_tier_for(key, context: context)
|
|
232
|
-
amount = non_negative_decimal(amount, label: "service charge price amount for #{key.inspect} in #{context}")
|
|
233
|
-
|
|
234
|
-
rate = {
|
|
235
|
-
amount: amount,
|
|
236
|
-
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
237
|
-
currency: currency,
|
|
238
|
-
source_key: key
|
|
239
|
-
}
|
|
240
|
-
dimension_rates = rates[dimension.key] ||= { tiers: {} }
|
|
241
|
-
(tier ? dimension_rates[:tiers] : dimension_rates)[tier || :default] = rate
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def dimension_and_tier_for(key, context:)
|
|
246
|
-
dimension, tier = PriceKey.parse_dimension_key(key)
|
|
247
|
-
unless dimension && dimension.token_key.nil?
|
|
248
|
-
raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing dimension"
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
[dimension, tier]
|
|
252
|
-
end
|
|
253
|
-
|
|
254
181
|
def upcased_currency(value)
|
|
255
182
|
(value || LlmCostTracker::DEFAULT_CURRENCY).upcase
|
|
256
183
|
end
|
|
@@ -3,34 +3,54 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Pricing
|
|
5
5
|
module Sync
|
|
6
|
-
|
|
6
|
+
class ChangePrinter
|
|
7
7
|
def self.call(changes, suspicious: [], output: $stdout)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
new(output).call(changes, suspicious)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(output)
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(changes, suspicious)
|
|
16
|
+
print_suspicious(suspicious)
|
|
17
|
+
print_models(changes.except("service_charges"))
|
|
18
|
+
print_service_charges(changes["service_charges"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
def print_suspicious(findings)
|
|
24
|
+
return unless findings.any?
|
|
15
25
|
|
|
16
|
-
output.puts "
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
@output.puts " suspicious changes (refresh writes them only with FORCE=1): #{findings.size}"
|
|
27
|
+
findings.each { |finding| @output.puts " - #{finding}" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def print_models(changes)
|
|
31
|
+
@output.puts " changed models: #{changes.size}"
|
|
32
|
+
changes.each do |model, fields|
|
|
33
|
+
@output.puts " - #{model}"
|
|
34
|
+
fields.each { |field, values| @output.puts " #{field}: #{transition(values)}" }
|
|
22
35
|
end
|
|
36
|
+
end
|
|
23
37
|
|
|
24
|
-
|
|
38
|
+
def print_service_charges(changes)
|
|
39
|
+
return if changes.nil? || changes.empty?
|
|
25
40
|
|
|
26
|
-
output.puts " changed service charges: #{
|
|
27
|
-
|
|
28
|
-
components.each
|
|
29
|
-
output.puts " - #{provider}.#{component}: " \
|
|
30
|
-
"#{values['from'].inspect} -> #{values['to'].inspect}"
|
|
31
|
-
end
|
|
41
|
+
@output.puts " changed service charges: #{changes.values.sum(&:size)}"
|
|
42
|
+
changes.each do |provider, components|
|
|
43
|
+
components.each { |component, values| @output.puts " - #{provider}.#{component}: #{transition(values)}" }
|
|
32
44
|
end
|
|
33
45
|
end
|
|
46
|
+
|
|
47
|
+
def transition(values)
|
|
48
|
+
"#{shown(values['from'])} -> #{shown(values['to'])}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def shown(value)
|
|
52
|
+
value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
|
|
53
|
+
end
|
|
34
54
|
end
|
|
35
55
|
end
|
|
36
56
|
end
|
|
@@ -26,55 +26,57 @@ module LlmCostTracker
|
|
|
26
26
|
READ_TIMEOUT = 10
|
|
27
27
|
WRITE_TIMEOUT = 10
|
|
28
28
|
|
|
29
|
+
def self.scrub_url(url)
|
|
30
|
+
uri = URI.parse(url.to_s)
|
|
31
|
+
uri.user = nil
|
|
32
|
+
uri.password = nil
|
|
33
|
+
uri.query = nil
|
|
34
|
+
uri.fragment = nil
|
|
35
|
+
uri.to_s
|
|
36
|
+
rescue URI::InvalidURIError
|
|
37
|
+
"[invalid url]"
|
|
38
|
+
end
|
|
39
|
+
|
|
29
40
|
def get(url, etag: nil, redirects: 0)
|
|
30
|
-
safe_url = scrub_url(url)
|
|
41
|
+
safe_url = self.class.scrub_url(url)
|
|
31
42
|
raise Error, "Too many redirects while fetching #{safe_url}" if redirects > MAX_REDIRECTS
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
|
|
35
|
-
|
|
36
|
-
request = Net::HTTP::Get.new(uri)
|
|
37
|
-
request["User-Agent"] = USER_AGENT
|
|
38
|
-
request["If-None-Match"] = etag if etag
|
|
39
|
-
|
|
40
|
-
response, body = fetch_response(uri, request)
|
|
41
|
-
|
|
44
|
+
response, body = fetch_response(https_uri(url), etag)
|
|
42
45
|
case response
|
|
43
|
-
when Net::HTTPSuccess
|
|
44
|
-
|
|
45
|
-
when Net::
|
|
46
|
-
|
|
47
|
-
when Net::HTTPRedirection
|
|
48
|
-
location = response["location"]
|
|
49
|
-
raise Error, "Redirect without location while fetching #{safe_url}" if location.blank?
|
|
50
|
-
|
|
51
|
-
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
52
|
-
else
|
|
53
|
-
raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
|
|
46
|
+
when Net::HTTPSuccess then build_response(response, body: body, not_modified: false)
|
|
47
|
+
when Net::HTTPNotModified then build_response(response, body: nil, not_modified: true)
|
|
48
|
+
when Net::HTTPRedirection then get(redirect_url(url, response), etag: etag, redirects: redirects + 1)
|
|
49
|
+
else raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
|
|
54
50
|
end
|
|
55
51
|
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
56
|
-
raise Error, "Unable to fetch #{scrub_url(url)}: #{e.class}: #{e.message}"
|
|
52
|
+
raise Error, "Unable to fetch #{self.class.scrub_url(url)}: #{e.class}: #{e.message}"
|
|
57
53
|
end
|
|
58
54
|
|
|
59
55
|
private
|
|
60
56
|
|
|
61
|
-
def
|
|
62
|
-
uri = URI.parse(url
|
|
63
|
-
uri.
|
|
64
|
-
|
|
65
|
-
uri
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"
|
|
57
|
+
def https_uri(url)
|
|
58
|
+
uri = URI.parse(url)
|
|
59
|
+
raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
|
|
60
|
+
|
|
61
|
+
uri
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def redirect_url(url, response)
|
|
65
|
+
location = response["location"]
|
|
66
|
+
raise Error, "Redirect without location while fetching #{self.class.scrub_url(url)}" if location.blank?
|
|
67
|
+
|
|
68
|
+
URI.join(url, location).to_s
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
def fetch_response(uri,
|
|
71
|
+
def fetch_response(uri, etag)
|
|
72
|
+
request = Net::HTTP::Get.new(uri)
|
|
73
|
+
request["User-Agent"] = USER_AGENT
|
|
74
|
+
request["If-None-Match"] = etag if etag
|
|
73
75
|
body = nil
|
|
74
76
|
response = Net::HTTP.start(
|
|
75
77
|
uri.host,
|
|
76
78
|
uri.port,
|
|
77
|
-
use_ssl:
|
|
79
|
+
use_ssl: true,
|
|
78
80
|
open_timeout: OPEN_TIMEOUT,
|
|
79
81
|
read_timeout: READ_TIMEOUT,
|
|
80
82
|
write_timeout: WRITE_TIMEOUT
|
|
@@ -83,7 +85,6 @@ module LlmCostTracker
|
|
|
83
85
|
body = limited_body(streamed_response) if streamed_response.is_a?(Net::HTTPSuccess)
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
|
-
|
|
87
88
|
[response, body]
|
|
88
89
|
end
|
|
89
90
|
|
|
@@ -5,6 +5,8 @@ require "fileutils"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "yaml"
|
|
7
7
|
|
|
8
|
+
require_relative "../registry"
|
|
9
|
+
|
|
8
10
|
module LlmCostTracker
|
|
9
11
|
module Pricing
|
|
10
12
|
module Sync
|
|
@@ -12,6 +14,10 @@ module LlmCostTracker
|
|
|
12
14
|
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
13
15
|
MANUAL_SOURCE = "manual"
|
|
14
16
|
|
|
17
|
+
def initialize(keep_unlisted: true)
|
|
18
|
+
@keep_unlisted = keep_unlisted
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
def call(path:, registry:)
|
|
16
22
|
payload = render(path: path, registry: registry)
|
|
17
23
|
FileUtils.mkdir_p(File.dirname(path))
|
|
@@ -23,23 +29,9 @@ module LlmCostTracker
|
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
def render(path:, registry:)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def canonicalize(value)
|
|
33
|
-
case value
|
|
34
|
-
when Hash
|
|
35
|
-
value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [key, canonicalize(nested)] }
|
|
36
|
-
when Array
|
|
37
|
-
value.map { |element| canonicalize(element) }
|
|
38
|
-
when BigDecimal
|
|
39
|
-
value.to_f
|
|
40
|
-
else
|
|
41
|
-
value
|
|
42
|
-
end
|
|
32
|
+
json = !yaml_file?(path)
|
|
33
|
+
merged = canonicalize(merge_with_existing(path: path, registry: registry), json: json)
|
|
34
|
+
json ? "#{JSON.pretty_generate(merged)}\n" : YAML.dump(merged)
|
|
43
35
|
end
|
|
44
36
|
|
|
45
37
|
def merge_with_existing(path:, registry:)
|
|
@@ -54,10 +46,25 @@ module LlmCostTracker
|
|
|
54
46
|
merged
|
|
55
47
|
end
|
|
56
48
|
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def canonicalize(value, json:)
|
|
52
|
+
case value
|
|
53
|
+
when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key, canonicalize(item, json: json)] }
|
|
54
|
+
when Array then value.map { |item| canonicalize(item, json: json) }
|
|
55
|
+
when BigDecimal, Float then canonical_number(value, json: json)
|
|
56
|
+
else value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def canonical_number(value, json:)
|
|
61
|
+
json && defined?(JSON::Fragment) ? JSON::Fragment.new(value.to_f.to_s) : value.to_f
|
|
62
|
+
end
|
|
63
|
+
|
|
57
64
|
def merged_models(registry, existing)
|
|
58
65
|
merged = registry.fetch("models", {}).dup
|
|
59
66
|
existing.fetch("models", {}).each do |model, attrs|
|
|
60
|
-
next unless attrs.is_a?(Hash) && attrs
|
|
67
|
+
next unless attrs.is_a?(Hash) && own_entry?(model, attrs)
|
|
61
68
|
next if merged.key?(model)
|
|
62
69
|
|
|
63
70
|
merged[model] = attrs
|
|
@@ -65,6 +72,10 @@ module LlmCostTracker
|
|
|
65
72
|
merged
|
|
66
73
|
end
|
|
67
74
|
|
|
75
|
+
def own_entry?(model, attrs)
|
|
76
|
+
attrs["_source"].to_s == MANUAL_SOURCE || (@keep_unlisted && !Registry.builtin_prices.key?(model))
|
|
77
|
+
end
|
|
78
|
+
|
|
68
79
|
def merged_service_charges(registry, existing)
|
|
69
80
|
remote = registry.fetch("service_charges", {})
|
|
70
81
|
existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rubygems"
|
|
6
|
+
|
|
7
|
+
require_relative "fetcher"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Pricing
|
|
11
|
+
module Sync
|
|
12
|
+
class RemoteSnapshot
|
|
13
|
+
CONTEXT = "remote pricing snapshot"
|
|
14
|
+
|
|
15
|
+
def initialize(body)
|
|
16
|
+
@registry = parse(body)
|
|
17
|
+
@metadata = @registry.fetch("metadata", {})
|
|
18
|
+
raise Error, "remote pricing metadata must be a hash" unless @metadata.is_a?(Hash)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def registry(url:, source_version:, today:)
|
|
22
|
+
check_compatibility!
|
|
23
|
+
models = models_with_metadata
|
|
24
|
+
Registry.rates_from_registry(@registry, context: CONTEXT) if service_charges
|
|
25
|
+
{
|
|
26
|
+
"metadata" => @metadata.merge(
|
|
27
|
+
"schema_version" => schema_version,
|
|
28
|
+
"updated_at" => @metadata["updated_at"] || today.iso8601,
|
|
29
|
+
"source_url" => Fetcher.scrub_url(url),
|
|
30
|
+
"source_version" => source_version
|
|
31
|
+
),
|
|
32
|
+
"models" => models,
|
|
33
|
+
"service_charges" => service_charges.presence
|
|
34
|
+
}.compact
|
|
35
|
+
rescue ArgumentError, TypeError => e
|
|
36
|
+
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def parse(body)
|
|
42
|
+
registry = JSON.parse(body.to_s)
|
|
43
|
+
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
registry
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_compatibility!
|
|
51
|
+
if schema_version > SUPPORTED_SCHEMA_VERSION
|
|
52
|
+
raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
min_gem_version = @metadata["min_gem_version"]
|
|
56
|
+
return unless min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
|
|
57
|
+
|
|
58
|
+
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def schema_version
|
|
62
|
+
@schema_version ||= Integer(@metadata.fetch("schema_version", 1))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def models_with_metadata
|
|
66
|
+
raw_models = @registry.fetch("models", {})
|
|
67
|
+
Registry.normalize_price_entries(raw_models, context: CONTEXT).to_h do |model, prices|
|
|
68
|
+
[model, (raw_models[model] || {}).slice(*Registry::METADATA_KEYS).merge(prices)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def service_charges
|
|
73
|
+
@registry["service_charges"]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -22,6 +22,8 @@ module LlmCostTracker
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
24
|
def finding(label, field, values)
|
|
25
|
+
return if field == Registry::OFF_PEAK_WINDOWS_KEY
|
|
26
|
+
|
|
25
27
|
from, to = values.values_at("from", "to").map { |value| value&.to_f }
|
|
26
28
|
"#{label}: #{from.inspect} -> #{to.inspect}" if suspicious?(field, from, to)
|
|
27
29
|
end
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
4
|
require "date"
|
|
5
|
-
require "json"
|
|
6
|
-
require "rubygems"
|
|
7
5
|
|
|
8
6
|
require_relative "registry"
|
|
9
7
|
require_relative "sync/fetcher"
|
|
10
8
|
require_relative "sync/registry_diff"
|
|
11
9
|
require_relative "sync/registry_writer"
|
|
10
|
+
require_relative "sync/remote_snapshot"
|
|
12
11
|
require_relative "sync/snapshot_guard"
|
|
13
12
|
|
|
14
13
|
module LlmCostTracker
|
|
@@ -52,7 +51,7 @@ module LlmCostTracker
|
|
|
52
51
|
end
|
|
53
52
|
RefreshResult.new(
|
|
54
53
|
path: path,
|
|
55
|
-
source_url:
|
|
54
|
+
source_url: Fetcher.scrub_url(url),
|
|
56
55
|
source_version: response.source_version,
|
|
57
56
|
changes: changes,
|
|
58
57
|
suspicious: suspicious,
|
|
@@ -65,7 +64,7 @@ module LlmCostTracker
|
|
|
65
64
|
response, _remote, changes, suspicious = compare(path, url, fetcher, today)
|
|
66
65
|
CheckResult.new(
|
|
67
66
|
path: path,
|
|
68
|
-
source_url:
|
|
67
|
+
source_url: Fetcher.scrub_url(url),
|
|
69
68
|
source_version: response.source_version,
|
|
70
69
|
changes: changes,
|
|
71
70
|
suspicious: suspicious,
|
|
@@ -75,51 +74,15 @@ module LlmCostTracker
|
|
|
75
74
|
|
|
76
75
|
private
|
|
77
76
|
|
|
78
|
-
def normalize_remote_registry(body, url:, response:, today:)
|
|
79
|
-
registry = parse_registry(body)
|
|
80
|
-
metadata = registry.fetch("metadata", {})
|
|
81
|
-
raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
|
|
82
|
-
|
|
83
|
-
schema_version = Integer(metadata.fetch("schema_version", 1))
|
|
84
|
-
if schema_version > SUPPORTED_SCHEMA_VERSION
|
|
85
|
-
raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
min_gem_version = metadata["min_gem_version"]
|
|
89
|
-
if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
|
|
90
|
-
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
raw_models = registry.fetch("models", {})
|
|
94
|
-
models = Registry.normalize_price_entries(raw_models, context: "remote pricing snapshot")
|
|
95
|
-
.each_with_object({}) do |(model, prices), normalized|
|
|
96
|
-
model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
|
|
97
|
-
normalized[model] = model_metadata.merge(prices)
|
|
98
|
-
end
|
|
99
|
-
service_charges = registry["service_charges"]
|
|
100
|
-
Registry.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
|
|
101
|
-
|
|
102
|
-
normalized = {
|
|
103
|
-
"metadata" => metadata.merge(
|
|
104
|
-
"schema_version" => schema_version,
|
|
105
|
-
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
106
|
-
"source_url" => Redaction.text(url),
|
|
107
|
-
"source_version" => response.source_version
|
|
108
|
-
),
|
|
109
|
-
"models" => models
|
|
110
|
-
}
|
|
111
|
-
normalized["service_charges"] = service_charges if service_charges.present?
|
|
112
|
-
normalized
|
|
113
|
-
rescue ArgumentError, TypeError => e
|
|
114
|
-
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
115
|
-
end
|
|
116
|
-
|
|
117
77
|
def compare(path, url, fetcher, today)
|
|
118
78
|
current = load_registry(path)
|
|
119
79
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
120
80
|
return [response, nil, {}, []] if response.not_modified
|
|
121
81
|
|
|
122
|
-
|
|
82
|
+
snapshot = RemoteSnapshot.new(response.body)
|
|
83
|
+
remote = RegistryWriter.new.merge_with_existing(
|
|
84
|
+
path: path, registry: snapshot.registry(url: url, source_version: response.source_version, today: today)
|
|
85
|
+
)
|
|
123
86
|
changes = registry_changes(current, remote)
|
|
124
87
|
[response, remote, changes, SnapshotGuard.call(current: current, remote: remote, changes: changes)]
|
|
125
88
|
end
|
|
@@ -132,15 +95,6 @@ module LlmCostTracker
|
|
|
132
95
|
raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
|
|
133
96
|
end
|
|
134
97
|
|
|
135
|
-
def parse_registry(body)
|
|
136
|
-
registry = JSON.parse(body.to_s)
|
|
137
|
-
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
138
|
-
|
|
139
|
-
registry
|
|
140
|
-
rescue JSON::ParserError => e
|
|
141
|
-
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
142
|
-
end
|
|
143
|
-
|
|
144
98
|
def refuse_suspicious_snapshot!(path, suspicious)
|
|
145
99
|
listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
|
|
146
100
|
raise Error,
|
|
@@ -38,7 +38,7 @@ module LlmCostTracker
|
|
|
38
38
|
subject += " at pricing_mode #{pricing_mode.inspect}" if pricing_mode
|
|
39
39
|
Logging.warn(
|
|
40
40
|
"No pricing configured for #{subject}. " \
|
|
41
|
-
"Cost and
|
|
41
|
+
"Cost and spend budgets will be skipped for this event. " \
|
|
42
42
|
"Add a pricing.overrides entry or set pricing.unknown_model_behavior."
|
|
43
43
|
)
|
|
44
44
|
end
|