llm_cost_tracker 0.13.0 → 0.14.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 +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +25 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- metadata +37 -12
|
@@ -4,6 +4,7 @@ require_relative "../pricing"
|
|
|
4
4
|
require_relative "../charges/line_item"
|
|
5
5
|
require_relative "../ledger/rollups"
|
|
6
6
|
require_relative "../usage/token_usage"
|
|
7
|
+
require_relative "../budget/per_tag"
|
|
7
8
|
|
|
8
9
|
module LlmCostTracker
|
|
9
10
|
module Pricing
|
|
@@ -30,7 +31,7 @@ module LlmCostTracker
|
|
|
30
31
|
rollup_events << rollup_event_for(call, calculation)
|
|
31
32
|
recomputed += 1
|
|
32
33
|
end
|
|
33
|
-
Ledger::Rollups.increment!(rollup_events)
|
|
34
|
+
Ledger::Rollups.increment!(rollup_events)
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -61,6 +62,7 @@ module LlmCostTracker
|
|
|
61
62
|
pricing_snapshot: calculation.snapshot,
|
|
62
63
|
cost_status: calculation.cost_status
|
|
63
64
|
)
|
|
65
|
+
resync_tag_costs(call, calculation.cost.total)
|
|
64
66
|
token_priced = calculation.priced_line_items.select(&:token?).index_by { |item| dimension_key(item) }
|
|
65
67
|
service_priced = calculation.priced_line_items.reject(&:token?)
|
|
66
68
|
token_records, service_records = call.line_items.partition { |record| record.unit == "token" }
|
|
@@ -69,6 +71,14 @@ module LlmCostTracker
|
|
|
69
71
|
service_records.sort_by(&:position).zip(service_priced).each { |record, priced| apply_rate(record, priced) }
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
def resync_tag_costs(call, total_cost)
|
|
75
|
+
return unless LlmCostTracker::Budget::PerTag.columns?
|
|
76
|
+
|
|
77
|
+
LlmCostTracker::CallTag
|
|
78
|
+
.where(llm_cost_tracker_call_id: call.id)
|
|
79
|
+
.update_all(total_cost: total_cost)
|
|
80
|
+
end
|
|
81
|
+
|
|
72
82
|
def apply_rate(record, priced)
|
|
73
83
|
return unless priced
|
|
74
84
|
|
|
@@ -187,16 +187,16 @@ module LlmCostTracker
|
|
|
187
187
|
price = effective[dimension.key]
|
|
188
188
|
return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
|
|
189
189
|
|
|
190
|
-
line_item.with_rate(token_rate(
|
|
190
|
+
line_item.with_rate(token_rate(price))
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
def token_rate(
|
|
193
|
+
def token_rate(price)
|
|
194
194
|
Pricing::Rate.new(
|
|
195
|
-
amount: price.to_d,
|
|
195
|
+
amount: price.amount.to_d,
|
|
196
196
|
quantity: RATE_DENOMINATOR_TOKENS.to_d,
|
|
197
197
|
currency: match.source.currency,
|
|
198
198
|
source: match.source.name,
|
|
199
|
-
source_key:
|
|
199
|
+
source_key: price.key,
|
|
200
200
|
source_version: match.source.version
|
|
201
201
|
)
|
|
202
202
|
end
|
|
@@ -8,38 +8,49 @@ require_relative "price_key"
|
|
|
8
8
|
module LlmCostTracker
|
|
9
9
|
module Pricing
|
|
10
10
|
module EffectivePrices
|
|
11
|
+
Resolved = Data.define(:amount, :key)
|
|
12
|
+
|
|
11
13
|
class << self
|
|
12
14
|
def call(usage:, quantities:, prices:, pricing_mode:)
|
|
13
15
|
context_tier = context_tier?(usage: usage, prices: prices)
|
|
14
16
|
orderings = pricing_mode && Mode.permutations_for(pricing_mode)
|
|
15
17
|
|
|
16
18
|
quantities.to_h do |price_key, tokens|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
[price_key,
|
|
19
|
+
resolved = if tokens.positive?
|
|
20
|
+
price_for(
|
|
21
|
+
prices: prices,
|
|
22
|
+
key: price_key,
|
|
23
|
+
orderings: orderings,
|
|
24
|
+
context_tier: context_tier
|
|
25
|
+
)
|
|
26
|
+
else
|
|
27
|
+
Resolved.new(amount: BigDecimal("0"), key: price_key)
|
|
28
|
+
end
|
|
29
|
+
[price_key, resolved]
|
|
28
30
|
end
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
private
|
|
32
34
|
|
|
33
35
|
def price_for(prices:, key:, orderings:, context_tier:)
|
|
34
|
-
|
|
36
|
+
unless orderings
|
|
37
|
+
standard = PriceKey.build(key, above_context: context_tier)
|
|
38
|
+
return resolve(prices[standard], standard)
|
|
39
|
+
end
|
|
35
40
|
|
|
36
41
|
orderings.each do |mode|
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
table_key = PriceKey.build(key, mode: mode, above_context: context_tier)
|
|
43
|
+
direct = prices[table_key]
|
|
44
|
+
return resolve(direct, table_key) if direct
|
|
39
45
|
end
|
|
40
46
|
return nil if %w[input output].include?(key)
|
|
41
47
|
|
|
42
|
-
derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
|
|
48
|
+
derived = derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
|
|
49
|
+
resolve(derived, nil)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve(amount, key)
|
|
53
|
+
amount && Resolved.new(amount: amount, key: key)
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
def derived_mode_price(prices:, key:, modes:, context_tier:)
|
|
@@ -27,6 +27,13 @@ module LlmCostTracker
|
|
|
27
27
|
@cache[key] = lookup_match(sources, provider_name, model_name)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def modifier_priced?(provider:, model:, modifier:)
|
|
31
|
+
prices = lookup(provider: provider, model: model)&.prices
|
|
32
|
+
return false unless prices
|
|
33
|
+
|
|
34
|
+
prices.any? { |key, _| key.to_s.include?(modifier) }
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
private
|
|
31
38
|
|
|
32
39
|
def reset_cache(sources)
|
|
@@ -9,8 +9,7 @@ module LlmCostTracker
|
|
|
9
9
|
"per_1k_requests" => 1_000,
|
|
10
10
|
"per_session" => 1,
|
|
11
11
|
"per_hour" => 1,
|
|
12
|
-
"per_minute" => 1
|
|
13
|
-
"per_image" => 1
|
|
12
|
+
"per_minute" => 1
|
|
14
13
|
}.freeze
|
|
15
14
|
|
|
16
15
|
Rate = Data.define(:amount, :quantity, :currency, :source, :source_key, :source_version)
|
|
@@ -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"
|
|
@@ -101,7 +107,7 @@ module LlmCostTracker
|
|
|
101
107
|
end
|
|
102
108
|
|
|
103
109
|
def prices_file_mtime_iso
|
|
104
|
-
path = LlmCostTracker.configuration.
|
|
110
|
+
path = LlmCostTracker.configuration.pricing.file
|
|
105
111
|
return nil unless path && File.exist?(path)
|
|
106
112
|
|
|
107
113
|
@prices_file_mtime_iso ||= File.mtime(path).utc.iso8601
|
|
@@ -113,16 +119,16 @@ module LlmCostTracker
|
|
|
113
119
|
[
|
|
114
120
|
Source.new(
|
|
115
121
|
name: "pricing_overrides",
|
|
116
|
-
prices: config.
|
|
122
|
+
prices: config.pricing.overrides,
|
|
117
123
|
rates: {},
|
|
118
124
|
currency: upcased_currency(nil),
|
|
119
125
|
version: "configuration"
|
|
120
126
|
),
|
|
121
127
|
Source.new(
|
|
122
128
|
name: "prices_file",
|
|
123
|
-
prices: file_prices(config.
|
|
124
|
-
rates: file_rates(config.
|
|
125
|
-
currency: upcased_currency(file_metadata(config.
|
|
129
|
+
prices: file_prices(config.pricing.file),
|
|
130
|
+
rates: file_rates(config.pricing.file),
|
|
131
|
+
currency: upcased_currency(file_metadata(config.pricing.file)["currency"]),
|
|
126
132
|
version: prices_file_mtime_iso
|
|
127
133
|
),
|
|
128
134
|
Source.new(
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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,15 +19,15 @@ 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)
|
|
26
27
|
output = env["OUTPUT"].to_s.strip.presence
|
|
27
28
|
return output if output
|
|
28
29
|
|
|
29
|
-
prices_file = config.
|
|
30
|
+
prices_file = config.pricing.file
|
|
30
31
|
return prices_file.to_s if prices_file
|
|
31
32
|
|
|
32
33
|
Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
|
|
@@ -39,6 +40,7 @@ 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
46
|
current = load_registry(path)
|
|
@@ -49,15 +51,18 @@ module LlmCostTracker
|
|
|
49
51
|
path: path,
|
|
50
52
|
url: url,
|
|
51
53
|
response: response,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
changes: registry_changes(current, current),
|
|
55
|
+
suspicious: [],
|
|
54
56
|
written: false,
|
|
55
57
|
not_modified: true
|
|
56
58
|
)
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
62
|
+
changes = registry_changes(current, remote)
|
|
63
|
+
suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
|
|
60
64
|
unless preview
|
|
65
|
+
refuse_suspicious_snapshot!(path, suspicious) unless force || suspicious.empty?
|
|
61
66
|
RegistryWriter.new.call(path: path, registry: remote)
|
|
62
67
|
Pricing::Registry.reset!
|
|
63
68
|
end
|
|
@@ -65,8 +70,8 @@ module LlmCostTracker
|
|
|
65
70
|
path: path,
|
|
66
71
|
url: url,
|
|
67
72
|
response: response,
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
changes: changes,
|
|
74
|
+
suspicious: suspicious,
|
|
70
75
|
written: !preview,
|
|
71
76
|
not_modified: false
|
|
72
77
|
)
|
|
@@ -79,22 +84,25 @@ module LlmCostTracker
|
|
|
79
84
|
if response.not_modified
|
|
80
85
|
return CheckResult.new(
|
|
81
86
|
path: path,
|
|
82
|
-
source_url: url,
|
|
87
|
+
source_url: Redaction.text(url),
|
|
83
88
|
source_version: response.source_version,
|
|
84
89
|
changes: {},
|
|
90
|
+
suspicious: [],
|
|
85
91
|
up_to_date: true
|
|
86
92
|
)
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
90
96
|
changes = registry_changes(current, remote)
|
|
97
|
+
suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
|
|
91
98
|
|
|
92
99
|
CheckResult.new(
|
|
93
100
|
path: path,
|
|
94
|
-
source_url: url,
|
|
101
|
+
source_url: Redaction.text(url),
|
|
95
102
|
source_version: response.source_version,
|
|
96
103
|
changes: changes,
|
|
97
|
-
|
|
104
|
+
suspicious: suspicious,
|
|
105
|
+
up_to_date: changes.empty? && suspicious.empty?
|
|
98
106
|
)
|
|
99
107
|
end
|
|
100
108
|
|
|
@@ -128,7 +136,7 @@ module LlmCostTracker
|
|
|
128
136
|
"metadata" => metadata.merge(
|
|
129
137
|
"schema_version" => schema_version,
|
|
130
138
|
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
131
|
-
"source_url" => url,
|
|
139
|
+
"source_url" => Redaction.text(url),
|
|
132
140
|
"source_version" => response.source_version
|
|
133
141
|
),
|
|
134
142
|
"models" => models
|
|
@@ -156,17 +164,26 @@ module LlmCostTracker
|
|
|
156
164
|
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
157
165
|
end
|
|
158
166
|
|
|
159
|
-
def refresh_result(path:, url:, response:,
|
|
167
|
+
def refresh_result(path:, url:, response:, changes:, suspicious:, written:, not_modified:)
|
|
160
168
|
RefreshResult.new(
|
|
161
169
|
path: path,
|
|
162
|
-
source_url: url,
|
|
170
|
+
source_url: Redaction.text(url),
|
|
163
171
|
source_version: response.source_version,
|
|
164
|
-
changes:
|
|
172
|
+
changes: changes,
|
|
173
|
+
suspicious: suspicious,
|
|
165
174
|
written: written,
|
|
166
175
|
not_modified: not_modified
|
|
167
176
|
)
|
|
168
177
|
end
|
|
169
178
|
|
|
179
|
+
def refuse_suspicious_snapshot!(path, suspicious)
|
|
180
|
+
listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
|
|
181
|
+
raise Error,
|
|
182
|
+
"Refusing to write pricing file #{path}: the remote snapshot has #{suspicious.size} " \
|
|
183
|
+
"suspicious price change(s):#{listed}\n" \
|
|
184
|
+
"Review them with PREVIEW=1, then refresh with FORCE=1 (force: true) to accept them."
|
|
185
|
+
end
|
|
186
|
+
|
|
170
187
|
def registry_changes(current, remote)
|
|
171
188
|
model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
172
189
|
charge_changes = service_charges_diff(
|
|
@@ -9,14 +9,14 @@ module LlmCostTracker
|
|
|
9
9
|
WARN_CACHE_LIMIT = 1024
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
|
-
def process(model)
|
|
12
|
+
def process(model, pricing_mode: nil)
|
|
13
13
|
model = model.to_s.presence || Event::UNKNOWN_MODEL
|
|
14
14
|
|
|
15
|
-
case LlmCostTracker.configuration.
|
|
15
|
+
case LlmCostTracker.configuration.pricing.unknown_model_behavior
|
|
16
16
|
when :ignore
|
|
17
17
|
nil
|
|
18
18
|
when :warn
|
|
19
|
-
warn_missing(model)
|
|
19
|
+
warn_missing(model, pricing_mode.to_s.presence)
|
|
20
20
|
when :raise
|
|
21
21
|
raise UnknownPricingError.new(model: model)
|
|
22
22
|
end
|
|
@@ -24,19 +24,22 @@ module LlmCostTracker
|
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
|
-
def warn_missing(model)
|
|
27
|
+
def warn_missing(model, pricing_mode)
|
|
28
|
+
key = [model, pricing_mode].freeze
|
|
28
29
|
should_warn = MUTEX.synchronize do
|
|
29
30
|
@warned_models ||= Set.new
|
|
30
|
-
next false if @warned_models.size >= WARN_CACHE_LIMIT && !@warned_models.include?(
|
|
31
|
+
next false if @warned_models.size >= WARN_CACHE_LIMIT && !@warned_models.include?(key)
|
|
31
32
|
|
|
32
|
-
@warned_models.add?(
|
|
33
|
+
@warned_models.add?(key)
|
|
33
34
|
end
|
|
34
35
|
return unless should_warn
|
|
35
36
|
|
|
37
|
+
subject = "model #{model.inspect}"
|
|
38
|
+
subject += " at pricing_mode #{pricing_mode.inspect}" if pricing_mode
|
|
36
39
|
Logging.warn(
|
|
37
|
-
"No pricing configured for
|
|
40
|
+
"No pricing configured for #{subject}. " \
|
|
38
41
|
"Cost and budget guardrails will be skipped for this event. " \
|
|
39
|
-
"Add a
|
|
42
|
+
"Add a pricing.overrides entry or set pricing.unknown_model_behavior."
|
|
40
43
|
)
|
|
41
44
|
end
|
|
42
45
|
end
|
|
@@ -22,12 +22,13 @@ module LlmCostTracker
|
|
|
22
22
|
output_tokens: output,
|
|
23
23
|
cache_read_input_tokens: cache_read,
|
|
24
24
|
cache_write_input_tokens: cache_write,
|
|
25
|
-
cache_write_extended_input_tokens: cache_write_extended
|
|
25
|
+
cache_write_extended_input_tokens: cache_write_extended,
|
|
26
|
+
hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
|
|
26
27
|
)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def self.pricing_mode(request:, usage:)
|
|
30
|
-
speed = request&.dig(:speed)
|
|
31
|
+
speed = usage&.dig(:speed) || request&.dig(:speed)
|
|
31
32
|
service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
|
|
32
33
|
geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
|
|
33
34
|
|
|
@@ -38,6 +38,25 @@ module LlmCostTracker
|
|
|
38
38
|
match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
|
|
39
39
|
match && match[1]
|
|
40
40
|
end
|
|
41
|
+
|
|
42
|
+
def auto_enable_stream_usage?(request_url, request_parsed)
|
|
43
|
+
super && stream_options_api?(parsed_uri(request_url)) &&
|
|
44
|
+
!request_parsed.key?("data_sources") && !image_input?(request_parsed)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def stream_options_api?(uri)
|
|
50
|
+
uri.path.start_with?("/openai/v1/") ||
|
|
51
|
+
uri.query.to_s[/api-version=(\d{4}-\d{2}-\d{2})/, 1].to_s >= "2024-06-01"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def image_input?(request)
|
|
55
|
+
Array(request["messages"]).any? do |message|
|
|
56
|
+
message.is_a?(Hash) &&
|
|
57
|
+
Array(message["content"]).any? { |part| part.is_a?(Hash) && part["type"] == "image_url" }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
62
|
end
|
|
@@ -4,9 +4,6 @@ module LlmCostTracker
|
|
|
4
4
|
module Providers
|
|
5
5
|
module Openai
|
|
6
6
|
module ModelFamilies
|
|
7
|
-
DATA_RESIDENCY_MODEL_PATTERN =
|
|
8
|
-
/\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
|
|
9
|
-
|
|
10
7
|
IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
|
|
11
8
|
|
|
12
9
|
CHARACTER_BILLED_TTS_MODEL_PATTERN = /\Atts-1(-hd)?\z/
|
|
@@ -19,10 +16,6 @@ module LlmCostTracker
|
|
|
19
16
|
NON_REASONING_GPT5_PATTERN = /\Agpt-5(?:\.\d+)?-chat\b/i
|
|
20
17
|
|
|
21
18
|
CHAT_COMPLETIONS_SEARCH_MODEL_PATTERN = /-search-(?:preview|api)\b/i
|
|
22
|
-
def self.data_residency?(model)
|
|
23
|
-
model.to_s.match?(DATA_RESIDENCY_MODEL_PATTERN)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
19
|
def self.image_output?(model)
|
|
27
20
|
model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
|
|
28
21
|
end
|
|
@@ -16,7 +16,10 @@ module LlmCostTracker
|
|
|
16
16
|
class << self
|
|
17
17
|
def combined_pricing_mode(host:, model:, service_tier:)
|
|
18
18
|
modes = [Pricing::Mode.normalize(service_tier)]
|
|
19
|
-
|
|
19
|
+
if Hosts.data_residency?(host) &&
|
|
20
|
+
Pricing::Matcher.modifier_priced?(provider: "openai", model: model, modifier: "data_residency")
|
|
21
|
+
modes << "data_residency"
|
|
22
|
+
end
|
|
20
23
|
Pricing::Mode.compose(modes)
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -26,7 +29,7 @@ module LlmCostTracker
|
|
|
26
29
|
|
|
27
30
|
model = response["model"] || request["model"]
|
|
28
31
|
service_line_items =
|
|
29
|
-
ServiceCharges.service_line_items_for(response, request: request, model:
|
|
32
|
+
ServiceCharges.service_line_items_for(response, request: request, model: model) +
|
|
30
33
|
ServiceCharges.transcription_line_items(usage)
|
|
31
34
|
Event.build(
|
|
32
35
|
provider: provider,
|
|
@@ -67,10 +70,14 @@ module LlmCostTracker
|
|
|
67
70
|
build_unknown_stream_usage(**context)
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
def auto_enable_stream_usage?(request_url)
|
|
73
|
+
def auto_enable_stream_usage?(request_url, _request_parsed)
|
|
71
74
|
openai_chat_completions_url?(request_url)
|
|
72
75
|
end
|
|
73
76
|
|
|
77
|
+
def retain_stream_event?(data)
|
|
78
|
+
data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
private
|
|
75
82
|
|
|
76
83
|
def stream_capture_context(events:, request:, request_url:)
|
|
@@ -12,9 +12,11 @@ module LlmCostTracker
|
|
|
12
12
|
input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
|
|
13
13
|
output_tokens = (usage[:output_tokens] || usage[:completion_tokens]).to_i
|
|
14
14
|
cache_read = cache_read_input_tokens(usage)
|
|
15
|
-
|
|
15
|
+
cache_write = cache_write_input_tokens(usage)
|
|
16
|
+
uncached_input = [input_tokens - cache_read - cache_write, 0].max
|
|
17
|
+
image_input = uncached_input_tokens(usage, :image_tokens).clamp(0, uncached_input)
|
|
18
|
+
audio_input = uncached_input_tokens(usage, :audio_tokens).clamp(0, uncached_input - image_input)
|
|
16
19
|
audio_output = audio_output_tokens(usage)
|
|
17
|
-
image_input = image_input_tokens(usage)
|
|
18
20
|
image_output, regular_output = split_output(
|
|
19
21
|
output_tokens: output_tokens,
|
|
20
22
|
image_output_details: image_output_tokens(usage),
|
|
@@ -24,10 +26,11 @@ module LlmCostTracker
|
|
|
24
26
|
)
|
|
25
27
|
|
|
26
28
|
Usage::TokenUsage.build(
|
|
27
|
-
input_tokens:
|
|
29
|
+
input_tokens: uncached_input - audio_input - image_input,
|
|
28
30
|
output_tokens: regular_output,
|
|
29
31
|
total_tokens: usage[:total_tokens],
|
|
30
32
|
cache_read_input_tokens: cache_read,
|
|
33
|
+
cache_write_input_tokens: cache_write,
|
|
31
34
|
audio_input_tokens: audio_input,
|
|
32
35
|
audio_output_tokens: audio_output,
|
|
33
36
|
image_input_tokens: image_input,
|
|
@@ -46,12 +49,11 @@ module LlmCostTracker
|
|
|
46
49
|
return default_to_image ? [remainder, 0] : [0, remainder]
|
|
47
50
|
end
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
text_output = [output_tokens - image_output_details - audio_output, 0].max if text_output.zero?
|
|
51
|
-
[image_output_details, text_output]
|
|
52
|
+
[image_output_details, [output_tokens - image_output_details - audio_output, 0].max]
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def self.cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
|
|
56
|
+
def self.cache_write_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cache_write_tokens)
|
|
55
57
|
def self.hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
|
|
56
58
|
def self.audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
|
|
57
59
|
def self.audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
|
|
@@ -59,9 +61,13 @@ module LlmCostTracker
|
|
|
59
61
|
def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
|
|
60
62
|
def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
|
|
61
63
|
|
|
62
|
-
def self.
|
|
64
|
+
def self.uncached_input_tokens(usage, key)
|
|
65
|
+
detail(usage, INPUT_DETAIL_KEYS, key) - detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, key)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.detail(usage, containers, *path)
|
|
63
69
|
containers.each do |container|
|
|
64
|
-
value = usage.dig(container,
|
|
70
|
+
value = usage.dig(container, *path)
|
|
65
71
|
return value.to_i if value
|
|
66
72
|
end
|
|
67
73
|
0
|
|
@@ -14,7 +14,7 @@ module LlmCostTracker
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def provider_names
|
|
17
|
-
custom = LlmCostTracker.configuration.openai_compatible_providers.each_value.map do |provider|
|
|
17
|
+
custom = LlmCostTracker.configuration.capture.openai_compatible_providers.each_value.map do |provider|
|
|
18
18
|
provider.to_s.downcase
|
|
19
19
|
end
|
|
20
20
|
["openai_compatible", *custom].uniq
|
|
@@ -23,13 +23,17 @@ module LlmCostTracker
|
|
|
23
23
|
def provider_for_uri(uri)
|
|
24
24
|
return nil unless uri
|
|
25
25
|
|
|
26
|
-
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
26
|
+
LlmCostTracker.configuration.capture.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def provider_for(request_url)
|
|
31
31
|
self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
def auto_enable_stream_usage?(request_url, request_parsed)
|
|
35
|
+
super && Configuration::Capture::OPENAI_COMPATIBLE_PROVIDERS.key?(parsed_uri(request_url).host.to_s.downcase)
|
|
36
|
+
end
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
end
|