llm_cost_tracker 0.14.0 → 0.14.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 +128 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
- data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
- data/lib/llm_cost_tracker/integrations/base.rb +56 -19
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +14 -13
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
- data/lib/llm_cost_tracker/parsers.rb +12 -41
- data/lib/llm_cost_tracker/prices.json +3071 -249
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +17 -10
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +26 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +23 -10
|
@@ -16,7 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
module Registry
|
|
17
17
|
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
18
18
|
CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
|
|
19
|
-
PRICE_KEYS = Usage::Catalog.
|
|
19
|
+
PRICE_KEYS = Usage::Catalog.all.select(&:token?).map(&:key).freeze
|
|
20
20
|
METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
|
|
21
21
|
|
|
22
22
|
class << self
|
|
@@ -33,6 +33,12 @@ module LlmCostTracker
|
|
|
33
33
|
@prices_file_mtime_iso = nil
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def validate_file!(path)
|
|
37
|
+
file_prices(path)
|
|
38
|
+
file_rates(path)
|
|
39
|
+
file_metadata(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
def builtin_prices
|
|
37
43
|
@builtin_prices ||= normalize_price_entries(
|
|
38
44
|
raw_registry.fetch("models", {}), context: "bundled prices"
|
|
@@ -157,10 +163,15 @@ module LlmCostTracker
|
|
|
157
163
|
def loading(path)
|
|
158
164
|
yield
|
|
159
165
|
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
160
|
-
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
166
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}; fix or delete it"
|
|
161
167
|
end
|
|
162
168
|
|
|
163
169
|
def load_raw_file_registry(path)
|
|
170
|
+
unless File.exist?(path)
|
|
171
|
+
Logging.warn("pricing.file #{path} does not exist; using bundled prices until prices:refresh creates it")
|
|
172
|
+
return {}.freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
164
175
|
loading(path) { (YAML.safe_load_file(path, aliases: false) || {}).freeze }
|
|
165
176
|
end
|
|
166
177
|
|
|
@@ -174,10 +185,9 @@ module LlmCostTracker
|
|
|
174
185
|
def normalize_price_entry(model, price, context)
|
|
175
186
|
unknown = []
|
|
176
187
|
normalized = price.each_with_object({}) do |(key, value), acc|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
elsif registry_key
|
|
188
|
+
if key.to_s == CONTEXT_THRESHOLD_KEY
|
|
189
|
+
acc[CONTEXT_THRESHOLD_KEY] = Integer(value)
|
|
190
|
+
elsif (registry_key = PriceKey.price_key_for(key))
|
|
181
191
|
acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
|
|
182
192
|
elsif !METADATA_KEYS.include?(key)
|
|
183
193
|
unknown << key
|
|
@@ -202,12 +212,6 @@ module LlmCostTracker
|
|
|
202
212
|
)
|
|
203
213
|
end
|
|
204
214
|
|
|
205
|
-
def registry_key_for(key)
|
|
206
|
-
return CONTEXT_THRESHOLD_KEY if key.to_s == CONTEXT_THRESHOLD_KEY
|
|
207
|
-
|
|
208
|
-
PriceKey.price_key_for(key)
|
|
209
|
-
end
|
|
210
|
-
|
|
211
215
|
def validate_price_entry(price, model:, context:)
|
|
212
216
|
return {} if price.nil?
|
|
213
217
|
return price if price.is_a?(Hash)
|
|
@@ -229,7 +233,7 @@ module LlmCostTracker
|
|
|
229
233
|
|
|
230
234
|
rate = {
|
|
231
235
|
amount: amount,
|
|
232
|
-
quantity:
|
|
236
|
+
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
233
237
|
currency: currency,
|
|
234
238
|
source_key: key
|
|
235
239
|
}
|
|
@@ -247,10 +251,6 @@ module LlmCostTracker
|
|
|
247
251
|
[dimension, tier]
|
|
248
252
|
end
|
|
249
253
|
|
|
250
|
-
def rate_quantity(dimension)
|
|
251
|
-
Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d
|
|
252
|
-
end
|
|
253
|
-
|
|
254
254
|
def upcased_currency(value)
|
|
255
255
|
(value || LlmCostTracker::DEFAULT_CURRENCY).upcase
|
|
256
256
|
end
|
|
@@ -44,19 +44,12 @@ module LlmCostTracker
|
|
|
44
44
|
return rate if rate
|
|
45
45
|
|
|
46
46
|
tier_rates.each do |candidate, candidate_rate|
|
|
47
|
-
return candidate_rate if
|
|
47
|
+
return candidate_rate if "_#{pricing_mode}_".include?("_#{candidate}_")
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
dimension_rates[:default]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def tier_includes?(tier_name, candidate_name)
|
|
54
|
-
tier_name == candidate_name ||
|
|
55
|
-
tier_name.start_with?("#{candidate_name}_") ||
|
|
56
|
-
tier_name.end_with?("_#{candidate_name}") ||
|
|
57
|
-
tier_name.include?("_#{candidate_name}_")
|
|
58
|
-
end
|
|
59
|
-
|
|
60
53
|
def charge_dimension_key(dimension)
|
|
61
54
|
billing_dimension = Usage::Catalog[dimension]
|
|
62
55
|
return billing_dimension.key if billing_dimension && billing_dimension.token_key.nil?
|
|
@@ -4,7 +4,12 @@ module LlmCostTracker
|
|
|
4
4
|
module Pricing
|
|
5
5
|
module Sync
|
|
6
6
|
module ChangePrinter
|
|
7
|
-
def self.call(changes, output: $stdout)
|
|
7
|
+
def self.call(changes, suspicious: [], output: $stdout)
|
|
8
|
+
if suspicious.any?
|
|
9
|
+
output.puts " suspicious changes (refresh writes them only with FORCE=1): #{suspicious.size}"
|
|
10
|
+
suspicious.each { |finding| output.puts " - #{finding}" }
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
service_changes = changes["service_charges"]
|
|
9
14
|
model_changes = changes.except("service_charges")
|
|
10
15
|
|
|
@@ -6,22 +6,26 @@ module LlmCostTracker
|
|
|
6
6
|
module RegistryDiff
|
|
7
7
|
class << self
|
|
8
8
|
def call(current_models, updated_models)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
fields = price_field_changes(current_models[model], updated_models[model])
|
|
14
|
-
changes[model] = fields if fields.any?
|
|
15
|
-
end
|
|
9
|
+
nested(
|
|
10
|
+
Registry.normalize_price_entries(current_models, context: "current price table"),
|
|
11
|
+
Registry.normalize_price_entries(updated_models, context: "updated price table")
|
|
12
|
+
)
|
|
16
13
|
rescue ArgumentError, TypeError => e
|
|
17
14
|
raise Error, e.message
|
|
18
15
|
end
|
|
19
16
|
|
|
17
|
+
def nested(current, updated)
|
|
18
|
+
(current.keys | updated.keys).sort.each_with_object({}) do |key, changes|
|
|
19
|
+
fields = price_field_changes(current[key], updated[key])
|
|
20
|
+
changes[key] = fields if fields.any?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
private
|
|
21
25
|
|
|
22
26
|
def price_field_changes(current_entry, updated_entry)
|
|
23
|
-
current_price = current_entry || {}
|
|
24
|
-
updated_price = updated_entry || {}
|
|
27
|
+
current_price = (current_entry || {}).transform_keys(&:to_s)
|
|
28
|
+
updated_price = (updated_entry || {}).transform_keys(&:to_s)
|
|
25
29
|
|
|
26
30
|
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
27
31
|
from = current_price[field]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../currency"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Pricing
|
|
7
|
+
module Sync
|
|
8
|
+
module SnapshotGuard
|
|
9
|
+
class << self
|
|
10
|
+
def call(current:, remote:, changes:)
|
|
11
|
+
findings = changes.except("service_charges").flat_map do |model, fields|
|
|
12
|
+
next [] unless remote["models"].key?(model)
|
|
13
|
+
|
|
14
|
+
fields.map { |field, values| finding("#{model} #{field}", field, values) }
|
|
15
|
+
end
|
|
16
|
+
changes.fetch("service_charges", {}).each do |provider, components|
|
|
17
|
+
components.each { |component, values| findings << finding("#{provider}.#{component}", component, values) }
|
|
18
|
+
end
|
|
19
|
+
(findings << currency_finding(current, remote)).compact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def finding(label, field, values)
|
|
25
|
+
from, to = values.values_at("from", "to").map { |value| value&.to_f }
|
|
26
|
+
"#{label}: #{from.inspect} -> #{to.inspect}" if suspicious?(field, from, to)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def suspicious?(field, from, to)
|
|
30
|
+
return %w[input output].include?(field) && from.positive? if to.nil?
|
|
31
|
+
return false if from.nil?
|
|
32
|
+
return true if from.zero? || to.zero?
|
|
33
|
+
|
|
34
|
+
[to / from, from / to].max >= 100
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def currency_finding(current, remote)
|
|
38
|
+
return if current.empty?
|
|
39
|
+
|
|
40
|
+
from, to = [current, remote].map { |registry| registry.dig("metadata", "currency") || DEFAULT_CURRENCY }
|
|
41
|
+
"currency: #{from} -> #{to}" unless from.upcase == to.upcase
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -9,6 +9,7 @@ require_relative "registry"
|
|
|
9
9
|
require_relative "sync/fetcher"
|
|
10
10
|
require_relative "sync/registry_diff"
|
|
11
11
|
require_relative "sync/registry_writer"
|
|
12
|
+
require_relative "sync/snapshot_guard"
|
|
12
13
|
|
|
13
14
|
module LlmCostTracker
|
|
14
15
|
module Pricing
|
|
@@ -18,8 +19,8 @@ module LlmCostTracker
|
|
|
18
19
|
"https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
|
|
19
20
|
SUPPORTED_SCHEMA_VERSION = 1
|
|
20
21
|
|
|
21
|
-
RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
|
|
22
|
-
CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
|
|
22
|
+
RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :suspicious, :written, :not_modified)
|
|
23
|
+
CheckResult = Data.define(:path, :source_url, :source_version, :changes, :suspicious, :up_to_date)
|
|
23
24
|
|
|
24
25
|
class << self
|
|
25
26
|
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
@@ -39,62 +40,36 @@ module LlmCostTracker
|
|
|
39
40
|
def refresh(path: DEFAULT_OUTPUT_PATH,
|
|
40
41
|
url: DEFAULT_REMOTE_URL,
|
|
41
42
|
preview: false,
|
|
43
|
+
force: false,
|
|
42
44
|
fetcher: Fetcher.new,
|
|
43
45
|
today: Date.today)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return refresh_result(
|
|
49
|
-
path: path,
|
|
50
|
-
url: url,
|
|
51
|
-
response: response,
|
|
52
|
-
current: current,
|
|
53
|
-
remote: current,
|
|
54
|
-
written: false,
|
|
55
|
-
not_modified: true
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
60
|
-
unless preview
|
|
46
|
+
response, remote, changes, suspicious = compare(path, url, fetcher, today)
|
|
47
|
+
written = !preview && !response.not_modified
|
|
48
|
+
if written
|
|
49
|
+
refuse_suspicious_snapshot!(path, suspicious) unless force || suspicious.empty?
|
|
61
50
|
RegistryWriter.new.call(path: path, registry: remote)
|
|
62
51
|
Pricing::Registry.reset!
|
|
63
52
|
end
|
|
64
|
-
|
|
53
|
+
RefreshResult.new(
|
|
65
54
|
path: path,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
written:
|
|
71
|
-
not_modified:
|
|
55
|
+
source_url: Redaction.text(url),
|
|
56
|
+
source_version: response.source_version,
|
|
57
|
+
changes: changes,
|
|
58
|
+
suspicious: suspicious,
|
|
59
|
+
written: written,
|
|
60
|
+
not_modified: response.not_modified
|
|
72
61
|
)
|
|
73
62
|
end
|
|
74
63
|
|
|
75
64
|
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
76
|
-
|
|
77
|
-
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
78
|
-
|
|
79
|
-
if response.not_modified
|
|
80
|
-
return CheckResult.new(
|
|
81
|
-
path: path,
|
|
82
|
-
source_url: url,
|
|
83
|
-
source_version: response.source_version,
|
|
84
|
-
changes: {},
|
|
85
|
-
up_to_date: true
|
|
86
|
-
)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
90
|
-
changes = registry_changes(current, remote)
|
|
91
|
-
|
|
65
|
+
response, _remote, changes, suspicious = compare(path, url, fetcher, today)
|
|
92
66
|
CheckResult.new(
|
|
93
67
|
path: path,
|
|
94
|
-
source_url: url,
|
|
68
|
+
source_url: Redaction.text(url),
|
|
95
69
|
source_version: response.source_version,
|
|
96
70
|
changes: changes,
|
|
97
|
-
|
|
71
|
+
suspicious: suspicious,
|
|
72
|
+
up_to_date: changes.empty? && suspicious.empty?
|
|
98
73
|
)
|
|
99
74
|
end
|
|
100
75
|
|
|
@@ -128,7 +103,7 @@ module LlmCostTracker
|
|
|
128
103
|
"metadata" => metadata.merge(
|
|
129
104
|
"schema_version" => schema_version,
|
|
130
105
|
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
131
|
-
"source_url" => url,
|
|
106
|
+
"source_url" => Redaction.text(url),
|
|
132
107
|
"source_version" => response.source_version
|
|
133
108
|
),
|
|
134
109
|
"models" => models
|
|
@@ -139,6 +114,16 @@ module LlmCostTracker
|
|
|
139
114
|
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
140
115
|
end
|
|
141
116
|
|
|
117
|
+
def compare(path, url, fetcher, today)
|
|
118
|
+
current = load_registry(path)
|
|
119
|
+
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
120
|
+
return [response, nil, {}, []] if response.not_modified
|
|
121
|
+
|
|
122
|
+
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
123
|
+
changes = registry_changes(current, remote)
|
|
124
|
+
[response, remote, changes, SnapshotGuard.call(current: current, remote: remote, changes: changes)]
|
|
125
|
+
end
|
|
126
|
+
|
|
142
127
|
def load_registry(path)
|
|
143
128
|
return {} unless File.exist?(path)
|
|
144
129
|
|
|
@@ -156,20 +141,18 @@ module LlmCostTracker
|
|
|
156
141
|
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
157
142
|
end
|
|
158
143
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
not_modified: not_modified
|
|
167
|
-
)
|
|
144
|
+
def refuse_suspicious_snapshot!(path, suspicious)
|
|
145
|
+
listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
|
|
146
|
+
raise Error,
|
|
147
|
+
"Refusing to write pricing file #{path}: the remote snapshot has #{suspicious.size} " \
|
|
148
|
+
"suspicious price change(s):#{listed}\n" \
|
|
149
|
+
"Review them with llm_cost_tracker:prices:check, " \
|
|
150
|
+
"then refresh with FORCE=1 (force: true) to accept them."
|
|
168
151
|
end
|
|
169
152
|
|
|
170
153
|
def registry_changes(current, remote)
|
|
171
154
|
model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
172
|
-
charge_changes =
|
|
155
|
+
charge_changes = RegistryDiff.nested(
|
|
173
156
|
current.fetch("service_charges", {}),
|
|
174
157
|
remote.fetch("service_charges", {})
|
|
175
158
|
)
|
|
@@ -177,21 +160,6 @@ module LlmCostTracker
|
|
|
177
160
|
|
|
178
161
|
model_changes.merge("service_charges" => charge_changes)
|
|
179
162
|
end
|
|
180
|
-
|
|
181
|
-
def service_charges_diff(current, remote)
|
|
182
|
-
(current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
|
|
183
|
-
current_rates = (current[provider] || {}).transform_keys(&:to_s)
|
|
184
|
-
remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
|
|
185
|
-
(current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
|
|
186
|
-
from = current_rates[component]
|
|
187
|
-
to = remote_rates[component]
|
|
188
|
-
next if from == to
|
|
189
|
-
|
|
190
|
-
changes[provider] ||= {}
|
|
191
|
-
changes[provider][component] = { "from" => from, "to" => to }
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
163
|
end
|
|
196
164
|
end
|
|
197
165
|
end
|
|
@@ -10,7 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def match?(url)
|
|
13
|
-
|
|
13
|
+
uri_matches?(url) do |uri|
|
|
14
|
+
HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.include?("/v1/messages")
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def provider_names
|
|
@@ -32,7 +34,9 @@ module LlmCostTracker
|
|
|
32
34
|
model: response["model"] || request[:model],
|
|
33
35
|
provider_response_id: response["id"],
|
|
34
36
|
usage_source: Usage::Source::RESPONSE,
|
|
35
|
-
request: request
|
|
37
|
+
request: request,
|
|
38
|
+
content: Array(response["content"]).grep(Hash).map(&:deep_symbolize_keys),
|
|
39
|
+
**stop_fields(response)
|
|
36
40
|
)
|
|
37
41
|
end
|
|
38
42
|
|
|
@@ -51,7 +55,9 @@ module LlmCostTracker
|
|
|
51
55
|
provider_response_id: response_id,
|
|
52
56
|
usage_source: Usage::Source::STREAM_FINAL,
|
|
53
57
|
request: request,
|
|
54
|
-
stream: true
|
|
58
|
+
stream: true,
|
|
59
|
+
content: content_blocks(events),
|
|
60
|
+
**stop_fields(final_delta(events))
|
|
55
61
|
)
|
|
56
62
|
else
|
|
57
63
|
build_unknown_stream_usage(
|
|
@@ -67,12 +73,36 @@ module LlmCostTracker
|
|
|
67
73
|
"anthropic"
|
|
68
74
|
end
|
|
69
75
|
|
|
76
|
+
def retain_stream_event?(data)
|
|
77
|
+
data.is_a?(Hash) && data.dig("content_block", "type") == "fallback"
|
|
78
|
+
end
|
|
79
|
+
|
|
70
80
|
private
|
|
71
81
|
|
|
72
82
|
def symbolize_request(request_body)
|
|
73
83
|
safe_json_parse(request_body).deep_symbolize_keys
|
|
74
84
|
end
|
|
75
85
|
|
|
86
|
+
def final_delta(events)
|
|
87
|
+
find_event_value(events, reverse: true) { |data| data["delta"] if data["type"] == "message_delta" }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def content_blocks(events)
|
|
91
|
+
blocks = []
|
|
92
|
+
each_event_data(events) do |data|
|
|
93
|
+
block = data["content_block"] if data["type"] == "content_block_start"
|
|
94
|
+
blocks << block.deep_symbolize_keys if block.is_a?(Hash)
|
|
95
|
+
end
|
|
96
|
+
blocks
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def stop_fields(source)
|
|
100
|
+
return {} unless source.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
details = source["stop_details"]
|
|
103
|
+
{ stop_reason: source["stop_reason"], refusal_category: (details["category"] if details.is_a?(Hash)) }
|
|
104
|
+
end
|
|
105
|
+
|
|
76
106
|
def stream_usage(events)
|
|
77
107
|
latest_delta = find_event_value(events, reverse: true) do |data|
|
|
78
108
|
data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
|
|
@@ -11,17 +11,23 @@ module LlmCostTracker
|
|
|
11
11
|
provider_response_id:,
|
|
12
12
|
usage_source:,
|
|
13
13
|
request: nil,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
stream: false,
|
|
15
|
+
stop_reason: nil,
|
|
16
|
+
refusal_category: nil,
|
|
17
|
+
content: nil)
|
|
18
|
+
pricing_mode = UsageExtractor.pricing_mode(request: request, usage: usage)
|
|
19
|
+
line_items = UsageExtractor.service_line_items(usage) +
|
|
20
|
+
UsageExtractor.iteration_line_items(usage, content: content) +
|
|
21
|
+
UsageExtractor.refusal_line_items(usage, stop_reason:, refusal_category:)
|
|
16
22
|
Event.build(
|
|
17
23
|
provider: "anthropic",
|
|
18
24
|
provider_response_id: provider_response_id,
|
|
19
|
-
pricing_mode: pricing_mode
|
|
20
|
-
model: model,
|
|
25
|
+
pricing_mode: pricing_mode,
|
|
26
|
+
model: UsageExtractor.served_model(usage, content) || model,
|
|
21
27
|
token_usage: UsageExtractor.token_usage(usage),
|
|
22
28
|
stream: stream,
|
|
23
29
|
usage_source: usage_source,
|
|
24
|
-
service_line_items:
|
|
30
|
+
service_line_items: line_items
|
|
25
31
|
)
|
|
26
32
|
end
|
|
27
33
|
end
|
|
@@ -9,34 +9,100 @@ module LlmCostTracker
|
|
|
9
9
|
"web_fetch_request" => :web_fetch_requests
|
|
10
10
|
}.freeze
|
|
11
11
|
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
12
|
-
|
|
12
|
+
BILLED_REFUSAL_CATEGORIES = %w[bio frontier_llm reasoning_extraction].freeze
|
|
13
|
+
BEDROCK_REGIONAL_PROFILE = /\A(?!global\.)[a-z]+\.anthropic\./
|
|
14
|
+
private_constant :SERVER_TOOL_LINE_ITEMS,
|
|
15
|
+
:DATA_RESIDENCY_GEOS,
|
|
16
|
+
:BILLED_REFUSAL_CATEGORIES,
|
|
17
|
+
:BEDROCK_REGIONAL_PROFILE
|
|
13
18
|
|
|
14
19
|
def self.token_usage(usage)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
cache_read = usage[:cache_read_input_tokens].to_i
|
|
18
|
-
cache_write, cache_write_extended = cache_writes(usage)
|
|
20
|
+
entries = [usage, *iterations(usage, "compaction")]
|
|
21
|
+
writes = entries.map { |entry| cache_writes(entry) }
|
|
19
22
|
|
|
20
23
|
Usage::TokenUsage.build(
|
|
21
|
-
input_tokens:
|
|
22
|
-
output_tokens:
|
|
23
|
-
cache_read_input_tokens:
|
|
24
|
-
cache_write_input_tokens:
|
|
25
|
-
cache_write_extended_input_tokens:
|
|
24
|
+
input_tokens: entries.sum { |entry| entry[:input_tokens].to_i },
|
|
25
|
+
output_tokens: entries.sum { |entry| entry[:output_tokens].to_i },
|
|
26
|
+
cache_read_input_tokens: entries.sum { |entry| entry[:cache_read_input_tokens].to_i },
|
|
27
|
+
cache_write_input_tokens: writes.sum(&:first),
|
|
28
|
+
cache_write_extended_input_tokens: writes.sum(&:last),
|
|
26
29
|
hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
|
|
27
30
|
)
|
|
28
31
|
end
|
|
29
32
|
|
|
33
|
+
def self.served_model(usage, content = nil)
|
|
34
|
+
served = iterations(usage, "fallback_message").last&.dig(:model)
|
|
35
|
+
served ||= fallback_blocks(content).last&.dig(:to, :model)
|
|
36
|
+
served&.to_s.presence
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.iteration_line_items(usage, content: nil)
|
|
40
|
+
other_model_iterations(usage, content).map do |iteration|
|
|
41
|
+
tokens = token_usage(iteration)
|
|
42
|
+
Charges::LineItem.build(
|
|
43
|
+
dimension_key: "model_iteration",
|
|
44
|
+
quantity: 1,
|
|
45
|
+
pricing_basis: "provider_usage",
|
|
46
|
+
provider_field: "usage.iterations.#{iteration[:type]}",
|
|
47
|
+
details: tokens.to_h.select { |_key, count| count.positive? }.merge(model: iteration[:model].to_s)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.refusal_line_items(usage, stop_reason:, refusal_category:)
|
|
53
|
+
return [] unless stop_reason.to_s == "refusal" && token_usage(usage).output_tokens.zero?
|
|
54
|
+
return [] if BILLED_REFUSAL_CATEGORIES.include?(refusal_category.to_s)
|
|
55
|
+
|
|
56
|
+
[Charges::LineItem.build(
|
|
57
|
+
dimension_key: "billed_request",
|
|
58
|
+
quantity: 1,
|
|
59
|
+
rate_amount: 0,
|
|
60
|
+
cost: 0,
|
|
61
|
+
pricing_basis: "provider_usage",
|
|
62
|
+
price_source: "provider_response",
|
|
63
|
+
provider_field: "stop_details.category",
|
|
64
|
+
details: { category: refusal_category&.to_s }.compact
|
|
65
|
+
)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.other_model_iterations(usage, content)
|
|
69
|
+
advisors = iterations(usage, "advisor_message")
|
|
70
|
+
served = served_model(usage, content)
|
|
71
|
+
return advisors unless served
|
|
72
|
+
|
|
73
|
+
categories = fallback_blocks(content).map { |block| block.dig(:trigger, :category).to_s }
|
|
74
|
+
declined = iterations(usage, "message").reject { |entry| ["", served].include?(entry[:model].to_s) }
|
|
75
|
+
hops = declined.group_by { |entry| entry[:model].to_s }.values
|
|
76
|
+
billed = hops.select.with_index do |hop, index|
|
|
77
|
+
hop.any? { |entry| entry[:output_tokens].to_i.positive? } ||
|
|
78
|
+
BILLED_REFUSAL_CATEGORIES.include?(categories[index])
|
|
79
|
+
end
|
|
80
|
+
advisors + billed.flatten
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.fallback_blocks(content)
|
|
84
|
+
Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.iterations(usage, type)
|
|
88
|
+
Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
|
|
89
|
+
end
|
|
90
|
+
|
|
30
91
|
def self.pricing_mode(request:, usage:)
|
|
31
92
|
speed = usage&.dig(:speed) || request&.dig(:speed)
|
|
32
93
|
service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
|
|
33
94
|
geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
|
|
34
95
|
|
|
35
96
|
modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
|
|
36
|
-
modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
|
|
97
|
+
modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo) || bedrock_regional?(request&.dig(:model))
|
|
37
98
|
Pricing::Mode.compose(modes)
|
|
38
99
|
end
|
|
39
100
|
|
|
101
|
+
def self.bedrock_regional?(model)
|
|
102
|
+
model.to_s.match?(BEDROCK_REGIONAL_PROFILE) &&
|
|
103
|
+
Pricing::Matcher.modifier_priced?(provider: "bedrock", model: model.to_s, modifier: "data_residency")
|
|
104
|
+
end
|
|
105
|
+
|
|
40
106
|
def self.service_line_items(usage)
|
|
41
107
|
server_tool_use = usage[:server_tool_use]
|
|
42
108
|
return [] unless server_tool_use.is_a?(Hash)
|
|
@@ -58,7 +124,10 @@ module LlmCostTracker
|
|
|
58
124
|
def self.cache_writes(usage)
|
|
59
125
|
cache_creation = usage[:cache_creation]
|
|
60
126
|
if cache_creation.is_a?(Hash)
|
|
61
|
-
|
|
127
|
+
five = cache_creation[:ephemeral_5m_input_tokens].to_i
|
|
128
|
+
one = cache_creation[:ephemeral_1h_input_tokens].to_i
|
|
129
|
+
# A stream's message_delta total also counts later server-tool breakpoints, which are always 5m.
|
|
130
|
+
[five + [usage[:cache_creation_input_tokens].to_i - five - one, 0].max, one]
|
|
62
131
|
else
|
|
63
132
|
warn_unexpected_cache_creation(cache_creation, usage)
|
|
64
133
|
[usage[:cache_creation_input_tokens].to_i, 0]
|
|
@@ -6,13 +6,9 @@ module LlmCostTracker
|
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include Openai::ResponseParser
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
images/generations images/edits images/variations
|
|
13
|
-
].freeze
|
|
14
|
-
|
|
15
|
-
PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
|
|
9
|
+
PATH_PATTERN = %r{
|
|
10
|
+
\A/openai/(?:deployments/[^/]+|v1)/(?:#{Openai::Parser::TRACKED_ENDPOINTS.join('|')}|responses/resp_[^/]+)\z
|
|
11
|
+
}x
|
|
16
12
|
|
|
17
13
|
class << self
|
|
18
14
|
def match?(url)
|
|
@@ -38,6 +34,25 @@ module LlmCostTracker
|
|
|
38
34
|
match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
|
|
39
35
|
match && match[1]
|
|
40
36
|
end
|
|
37
|
+
|
|
38
|
+
def auto_enable_stream_usage?(request_url, request_parsed)
|
|
39
|
+
super && stream_options_api?(parsed_uri(request_url)) &&
|
|
40
|
+
!request_parsed.key?("data_sources") && !image_input?(request_parsed)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def stream_options_api?(uri)
|
|
46
|
+
uri.path.start_with?("/openai/v1/") ||
|
|
47
|
+
uri.query.to_s[/api-version=(\d{4}-\d{2}-\d{2})/, 1].to_s >= "2024-06-01"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def image_input?(request)
|
|
51
|
+
Array(request["messages"]).any? do |message|
|
|
52
|
+
message.is_a?(Hash) &&
|
|
53
|
+
Array(message["content"]).any? { |part| part.is_a?(Hash) && part["type"] == "image_url" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
41
56
|
end
|
|
42
57
|
end
|
|
43
58
|
end
|