llm_cost_tracker 0.7.3 → 0.8.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/.ruby-version +1 -0
- data/CHANGELOG.md +66 -1
- data/README.md +58 -225
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +96 -13
- data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "yaml"
|
|
5
4
|
|
|
6
|
-
require_relative "components"
|
|
5
|
+
require_relative "../billing/components"
|
|
7
6
|
require_relative "../logging"
|
|
8
7
|
|
|
9
8
|
module LlmCostTracker
|
|
@@ -11,42 +10,58 @@ module LlmCostTracker
|
|
|
11
10
|
module Registry
|
|
12
11
|
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
13
12
|
EMPTY_PRICES = {}.freeze
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
CONTEXT_THRESHOLD_KEY = :_context_price_threshold_tokens
|
|
14
|
+
PRICE_KEYS = Billing::Components::TOKEN_PRICED.map { |component| component.key.name }.freeze
|
|
15
|
+
METADATA_KEYS = [
|
|
16
|
+
"_source", "_source_version", "_fetched_at", "_updated", "_notes", "_validator_override",
|
|
17
|
+
CONTEXT_THRESHOLD_KEY.name
|
|
18
18
|
].freeze
|
|
19
|
-
MAX_FILE_BYTES = 2_097_152
|
|
20
19
|
MUTEX = Mutex.new
|
|
21
20
|
|
|
22
21
|
class << self
|
|
22
|
+
def reset!
|
|
23
|
+
MUTEX.synchronize do
|
|
24
|
+
@builtin_prices = nil
|
|
25
|
+
@metadata = nil
|
|
26
|
+
@raw_registry = nil
|
|
27
|
+
@file_prices_cache = nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def builtin_prices
|
|
24
32
|
cached = @builtin_prices
|
|
25
33
|
return cached if cached
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
MUTEX.synchronize do
|
|
36
|
+
@builtin_prices ||= begin
|
|
37
|
+
registry = @raw_registry ||= load_raw_registry
|
|
38
|
+
normalize_price_table(registry.fetch("models", {})).freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
29
41
|
end
|
|
30
42
|
|
|
31
43
|
def metadata
|
|
32
44
|
cached = @metadata
|
|
33
45
|
return cached if cached
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
MUTEX.synchronize do
|
|
48
|
+
@metadata ||= begin
|
|
49
|
+
registry = @raw_registry ||= load_raw_registry
|
|
50
|
+
registry.fetch("metadata", {}).freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
def file_metadata(path)
|
|
40
56
|
return {} unless path
|
|
41
57
|
|
|
42
|
-
registry =
|
|
43
|
-
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
58
|
+
registry = YAML.safe_load_file(path, aliases: false) || {}
|
|
44
59
|
|
|
45
60
|
metadata = registry.fetch("metadata", {})
|
|
46
61
|
raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
|
|
47
62
|
|
|
48
63
|
metadata
|
|
49
|
-
rescue Errno::ENOENT,
|
|
64
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
50
65
|
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
51
66
|
end
|
|
52
67
|
|
|
@@ -57,8 +72,7 @@ module LlmCostTracker
|
|
|
57
72
|
def file_prices(path)
|
|
58
73
|
return EMPTY_PRICES unless path
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
cache_key = [path, File.mtime(path).to_f]
|
|
75
|
+
cache_key = [path, File.mtime(path)]
|
|
62
76
|
cached = @file_prices_cache
|
|
63
77
|
return cached[:value] if cached && cached[:key] == cache_key
|
|
64
78
|
|
|
@@ -66,11 +80,12 @@ module LlmCostTracker
|
|
|
66
80
|
cached = @file_prices_cache
|
|
67
81
|
return cached[:value] if cached && cached[:key] == cache_key
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
registry = YAML.safe_load_file(path, aliases: false) || {}
|
|
84
|
+
value = normalize_price_entries(registry.fetch("models", registry), context: path).freeze
|
|
70
85
|
@file_prices_cache = { key: cache_key, value: value }.freeze
|
|
71
86
|
value
|
|
72
87
|
end
|
|
73
|
-
rescue Errno::ENOENT,
|
|
88
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
74
89
|
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
75
90
|
end
|
|
76
91
|
|
|
@@ -80,20 +95,31 @@ module LlmCostTracker
|
|
|
80
95
|
cached = @raw_registry
|
|
81
96
|
return cached if cached
|
|
82
97
|
|
|
83
|
-
MUTEX.synchronize { @raw_registry ||=
|
|
98
|
+
MUTEX.synchronize { @raw_registry ||= load_raw_registry }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def load_raw_registry
|
|
102
|
+
YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
|
|
84
103
|
end
|
|
85
104
|
|
|
86
105
|
def normalize_price_entry(price)
|
|
87
106
|
price.each_with_object({}) do |(key, value), normalized|
|
|
88
|
-
key = key
|
|
89
|
-
if
|
|
90
|
-
normalized[key
|
|
91
|
-
elsif key
|
|
92
|
-
normalized[key
|
|
107
|
+
key = registry_key_for(key)
|
|
108
|
+
if key == CONTEXT_THRESHOLD_KEY
|
|
109
|
+
normalized[key] = Integer(value)
|
|
110
|
+
elsif key
|
|
111
|
+
normalized[key] = non_negative_float(key, value)
|
|
93
112
|
end
|
|
94
113
|
end
|
|
95
114
|
end
|
|
96
115
|
|
|
116
|
+
def non_negative_float(key, value)
|
|
117
|
+
rate = Float(value)
|
|
118
|
+
raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
|
|
119
|
+
|
|
120
|
+
rate
|
|
121
|
+
end
|
|
122
|
+
|
|
97
123
|
def normalize_price_entries(table, context:)
|
|
98
124
|
table = {} if table.nil?
|
|
99
125
|
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
@@ -106,8 +132,8 @@ module LlmCostTracker
|
|
|
106
132
|
end
|
|
107
133
|
|
|
108
134
|
def warn_unknown_keys(model, price, path)
|
|
109
|
-
unknown_keys = price.keys.
|
|
110
|
-
|
|
135
|
+
unknown_keys = price.keys.reject do |key|
|
|
136
|
+
registry_key_for(key) || METADATA_KEYS.include?(key)
|
|
111
137
|
end
|
|
112
138
|
return if unknown_keys.empty?
|
|
113
139
|
|
|
@@ -117,31 +143,25 @@ module LlmCostTracker
|
|
|
117
143
|
)
|
|
118
144
|
end
|
|
119
145
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def load_price_file(path)
|
|
129
|
-
raise ArgumentError, "prices_file exceeds #{MAX_FILE_BYTES} bytes" if File.size(path) > MAX_FILE_BYTES
|
|
146
|
+
def price_key_for(key)
|
|
147
|
+
name = key.is_a?(Symbol) ? key.name : key
|
|
148
|
+
Billing::Components::TOKEN_PRICED.each do |candidate|
|
|
149
|
+
return candidate.key if candidate.key.name == name
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
suffix = "_#{candidate.key.name}"
|
|
152
|
+
next unless name.end_with?(suffix)
|
|
133
153
|
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
prefix = name.delete_suffix(suffix)
|
|
155
|
+
return :"#{prefix}_#{candidate.key.name}" unless prefix.empty?
|
|
156
|
+
end
|
|
136
157
|
|
|
137
|
-
|
|
138
|
-
%w[.yaml .yml].include?(File.extname(path).downcase)
|
|
158
|
+
nil
|
|
139
159
|
end
|
|
140
160
|
|
|
141
|
-
def
|
|
142
|
-
|
|
161
|
+
def registry_key_for(key)
|
|
162
|
+
return CONTEXT_THRESHOLD_KEY if key == CONTEXT_THRESHOLD_KEY || key == CONTEXT_THRESHOLD_KEY.name
|
|
143
163
|
|
|
144
|
-
|
|
164
|
+
price_key_for(key)
|
|
145
165
|
end
|
|
146
166
|
|
|
147
167
|
def validate_price_entry(price, model:, context:)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "time"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
require_relative "../billing/components"
|
|
9
|
+
require_relative "registry"
|
|
10
|
+
|
|
11
|
+
module LlmCostTracker
|
|
12
|
+
module Pricing
|
|
13
|
+
module ServiceCharges
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
DEFAULT_CURRENCY = "USD"
|
|
17
|
+
EMPTY_RATES = {}.freeze
|
|
18
|
+
MUTEX = Mutex.new
|
|
19
|
+
|
|
20
|
+
def reset!
|
|
21
|
+
MUTEX.synchronize do
|
|
22
|
+
@builtin_rates = nil
|
|
23
|
+
@file_rates_cache = nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def builtin_rates
|
|
28
|
+
cached = @builtin_rates
|
|
29
|
+
return cached if cached
|
|
30
|
+
|
|
31
|
+
MUTEX.synchronize do
|
|
32
|
+
@builtin_rates ||= begin
|
|
33
|
+
registry = YAML.safe_load_file(Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
|
|
34
|
+
rates_from_registry(registry).freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def file_rates(path)
|
|
40
|
+
return EMPTY_RATES unless path
|
|
41
|
+
|
|
42
|
+
cache_key = [path, File.mtime(path)]
|
|
43
|
+
cached = @file_rates_cache
|
|
44
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
45
|
+
|
|
46
|
+
MUTEX.synchronize do
|
|
47
|
+
cached = @file_rates_cache
|
|
48
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
49
|
+
|
|
50
|
+
registry = YAML.safe_load_file(path, aliases: false) || {}
|
|
51
|
+
value = rates_from_registry(registry, context: path).freeze
|
|
52
|
+
@file_rates_cache = { key: cache_key, value: value }.freeze
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
56
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rates_from_registry(registry, context: "price registry")
|
|
60
|
+
data = registry.fetch("service_charges", EMPTY_RATES)
|
|
61
|
+
raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
data.each_with_object({}) do |(provider, entries), rates|
|
|
64
|
+
section_context = "#{context} service_charges.#{provider}"
|
|
65
|
+
rates[provider] = rates_from_section(entries, context: section_context)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def charge_rate(provider:, component:, pricing_mode:)
|
|
70
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode)
|
|
71
|
+
match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
|
|
72
|
+
return nil unless match
|
|
73
|
+
|
|
74
|
+
rate = match.fetch(:rate)
|
|
75
|
+
{
|
|
76
|
+
amount: rate.fetch(:amount),
|
|
77
|
+
quantity: rate.fetch(:quantity),
|
|
78
|
+
currency: rate.fetch(:currency),
|
|
79
|
+
source: match.fetch(:source),
|
|
80
|
+
source_key: match.fetch(:key),
|
|
81
|
+
source_version: rate_source_version_for(match.fetch(:source))
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def rates_from_section(entries, context:)
|
|
88
|
+
raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
entries.each_with_object({}) do |(key, amount), rates|
|
|
91
|
+
key = key.name if key.is_a?(Symbol)
|
|
92
|
+
component, tier = component_and_tier_for(key, context: context)
|
|
93
|
+
amount = amount_for(key, amount, context: context)
|
|
94
|
+
|
|
95
|
+
rate = {
|
|
96
|
+
amount: amount,
|
|
97
|
+
quantity: rate_quantity(component),
|
|
98
|
+
currency: DEFAULT_CURRENCY,
|
|
99
|
+
source_key: key
|
|
100
|
+
}
|
|
101
|
+
component_rates = rates[component.key] ||= { tiers: {} }
|
|
102
|
+
(tier ? component_rates[:tiers] : component_rates)[tier || :default] = rate
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def component_and_tier_for(key, context:)
|
|
107
|
+
Billing::Components::REGISTRY.each do |component|
|
|
108
|
+
next if component.token_key
|
|
109
|
+
|
|
110
|
+
return [component, nil] if key == component.key.name
|
|
111
|
+
|
|
112
|
+
suffix = "_#{component.key.name}"
|
|
113
|
+
next unless key.end_with?(suffix)
|
|
114
|
+
|
|
115
|
+
tier = key.delete_suffix(suffix)
|
|
116
|
+
return [component, :"#{tier}"] unless tier.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing component"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def amount_for(key, amount, context:)
|
|
123
|
+
value = BigDecimal(amount.to_s)
|
|
124
|
+
message = "service charge price amount for #{key.inspect} in #{context} must be non-negative"
|
|
125
|
+
raise ArgumentError, message if value.negative?
|
|
126
|
+
|
|
127
|
+
value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def rate_quantity(component)
|
|
131
|
+
component.unit == :request ? BigDecimal("1000") : BigDecimal("1")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def charge_rate_match(provider:, component:, pricing_mode:)
|
|
135
|
+
provider_name = provider.is_a?(Symbol) ? provider.name : provider.presence
|
|
136
|
+
return nil unless provider_name
|
|
137
|
+
|
|
138
|
+
component_key = charge_component_key(component)
|
|
139
|
+
|
|
140
|
+
table = ServiceCharges.file_rates(LlmCostTracker.configuration.prices_file)
|
|
141
|
+
provider_table = table.fetch(provider_name, EMPTY_RATES)
|
|
142
|
+
rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
|
|
143
|
+
if rate
|
|
144
|
+
return {
|
|
145
|
+
source: :prices_file,
|
|
146
|
+
key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
|
|
147
|
+
rate: rate
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
table = ServiceCharges.builtin_rates
|
|
152
|
+
provider_table = table.fetch(provider_name, EMPTY_RATES)
|
|
153
|
+
rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
|
|
154
|
+
return unless rate
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
source: :bundled,
|
|
158
|
+
key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
|
|
159
|
+
rate: rate
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def rate_for(provider_table, component_key:, pricing_mode:)
|
|
164
|
+
component_rates = provider_table.fetch(component_key, EMPTY_RATES)
|
|
165
|
+
tier_rates = component_rates.fetch(:tiers, EMPTY_RATES)
|
|
166
|
+
if pricing_mode
|
|
167
|
+
rate = tier_rates[pricing_mode]
|
|
168
|
+
return rate if rate
|
|
169
|
+
|
|
170
|
+
name = pricing_mode.name
|
|
171
|
+
tier_rates.each do |candidate, candidate_rate|
|
|
172
|
+
return candidate_rate if tier_includes?(name, candidate.name)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
component_rates[:default]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def tier_includes?(tier_name, candidate_name)
|
|
179
|
+
tier_name == candidate_name ||
|
|
180
|
+
tier_name.start_with?("#{candidate_name}_") ||
|
|
181
|
+
tier_name.end_with?("_#{candidate_name}") ||
|
|
182
|
+
tier_name.include?("_#{candidate_name}_")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def charge_component_key(component)
|
|
186
|
+
billing_component = Billing::Components::BY_KEY[component]
|
|
187
|
+
return billing_component.key if billing_component && billing_component.token_key.nil?
|
|
188
|
+
|
|
189
|
+
raise Error, "Unknown billing component: #{component.inspect}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def rate_source_version_for(source)
|
|
193
|
+
return LlmCostTracker::VERSION if source == :bundled
|
|
194
|
+
|
|
195
|
+
path = LlmCostTracker.configuration.prices_file
|
|
196
|
+
return nil unless path
|
|
197
|
+
|
|
198
|
+
File.mtime(path).utc.iso8601
|
|
199
|
+
rescue Errno::ENOENT
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -7,6 +7,8 @@ require "openssl"
|
|
|
7
7
|
require "time"
|
|
8
8
|
require "uri"
|
|
9
9
|
|
|
10
|
+
require_relative "../../version"
|
|
11
|
+
|
|
10
12
|
module LlmCostTracker
|
|
11
13
|
module Pricing
|
|
12
14
|
module Sync
|
|
@@ -17,7 +19,7 @@ module LlmCostTracker
|
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
USER_AGENT = "llm_cost_tracker price refresh"
|
|
22
|
+
USER_AGENT = "llm_cost_tracker/#{LlmCostTracker::VERSION} price refresh".freeze
|
|
21
23
|
MAX_REDIRECTS = 5
|
|
22
24
|
MAX_BODY_BYTES = 2_097_152
|
|
23
25
|
OPEN_TIMEOUT = 5
|
|
@@ -25,7 +27,8 @@ module LlmCostTracker
|
|
|
25
27
|
WRITE_TIMEOUT = 10
|
|
26
28
|
|
|
27
29
|
def get(url, etag: nil, redirects: 0)
|
|
28
|
-
|
|
30
|
+
safe_url = scrub_url(url)
|
|
31
|
+
raise Error, "Too many redirects while fetching #{safe_url}" if redirects > MAX_REDIRECTS
|
|
29
32
|
|
|
30
33
|
uri = URI.parse(url)
|
|
31
34
|
raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
|
|
@@ -38,23 +41,34 @@ module LlmCostTracker
|
|
|
38
41
|
|
|
39
42
|
case response
|
|
40
43
|
when Net::HTTPSuccess
|
|
41
|
-
build_response(response, body: body
|
|
44
|
+
build_response(response, body: body, not_modified: false)
|
|
42
45
|
when Net::HTTPNotModified
|
|
43
46
|
build_response(response, body: nil, not_modified: true)
|
|
44
47
|
when Net::HTTPRedirection
|
|
45
48
|
location = response["location"]
|
|
46
|
-
raise Error, "Redirect without location while fetching #{
|
|
49
|
+
raise Error, "Redirect without location while fetching #{safe_url}" if location.blank?
|
|
47
50
|
|
|
48
51
|
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
49
52
|
else
|
|
50
|
-
raise Error, "Unable to fetch #{
|
|
53
|
+
raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
|
|
51
54
|
end
|
|
52
55
|
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
53
|
-
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
56
|
+
raise Error, "Unable to fetch #{scrub_url(url)}: #{e.class}: #{e.message}"
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
private
|
|
57
60
|
|
|
61
|
+
def scrub_url(url)
|
|
62
|
+
uri = URI.parse(url.to_s)
|
|
63
|
+
uri.user = nil
|
|
64
|
+
uri.password = nil
|
|
65
|
+
uri.query = nil
|
|
66
|
+
uri.fragment = nil
|
|
67
|
+
uri.to_s
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
"[invalid url]"
|
|
70
|
+
end
|
|
71
|
+
|
|
58
72
|
def fetch_response(uri, request)
|
|
59
73
|
body = nil
|
|
60
74
|
response = Net::HTTP.start(
|
|
@@ -75,19 +89,14 @@ module LlmCostTracker
|
|
|
75
89
|
|
|
76
90
|
def limited_body(response)
|
|
77
91
|
body = +""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
body << chunk
|
|
92
|
+
response.read_body do |chunk|
|
|
93
|
+
chunk = chunk.to_s
|
|
94
|
+
if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
|
|
95
|
+
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
|
|
86
96
|
end
|
|
87
|
-
|
|
88
|
-
body
|
|
97
|
+
|
|
98
|
+
body << chunk
|
|
89
99
|
end
|
|
90
|
-
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
|
|
91
100
|
|
|
92
101
|
body
|
|
93
102
|
end
|
|
@@ -18,8 +18,8 @@ module LlmCostTracker
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
20
|
def price_field_changes(current_entry, updated_entry)
|
|
21
|
-
current_price =
|
|
22
|
-
updated_price =
|
|
21
|
+
current_price = current_entry || {}
|
|
22
|
+
updated_price = updated_entry || {}
|
|
23
23
|
|
|
24
24
|
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
25
25
|
from = current_price[field]
|
|
@@ -30,21 +30,12 @@ module LlmCostTracker
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def comparable_price(entry)
|
|
34
|
-
normalize_hash(entry).slice(*Registry::PRICE_KEYS)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
33
|
def normalize_models(models)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def normalize_hash(hash)
|
|
42
|
-
return {} if hash.nil?
|
|
43
|
-
raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
|
|
44
|
-
|
|
45
|
-
hash.each_with_object({}) do |(key, value), normalized|
|
|
46
|
-
normalized[key.to_s] = value
|
|
34
|
+
Registry.normalize_price_table(models).transform_values do |price|
|
|
35
|
+
price.to_h { |key, value| [key.name, value] }
|
|
47
36
|
end
|
|
37
|
+
rescue ArgumentError, TypeError => e
|
|
38
|
+
raise Error, e.message
|
|
48
39
|
end
|
|
49
40
|
end
|
|
50
41
|
end
|
|
@@ -8,7 +8,6 @@ require "rubygems"
|
|
|
8
8
|
require_relative "registry"
|
|
9
9
|
require_relative "sync/fetcher"
|
|
10
10
|
require_relative "sync/registry_diff"
|
|
11
|
-
require_relative "sync/registry_loader"
|
|
12
11
|
require_relative "sync/registry_writer"
|
|
13
12
|
|
|
14
13
|
module LlmCostTracker
|
|
@@ -39,7 +38,7 @@ module LlmCostTracker
|
|
|
39
38
|
|
|
40
39
|
def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
|
|
41
40
|
today: Date.today)
|
|
42
|
-
current =
|
|
41
|
+
current = load_registry(path)
|
|
43
42
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
44
43
|
|
|
45
44
|
if response.not_modified
|
|
@@ -55,7 +54,10 @@ module LlmCostTracker
|
|
|
55
54
|
end
|
|
56
55
|
|
|
57
56
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
58
|
-
|
|
57
|
+
unless preview
|
|
58
|
+
RegistryWriter.new.call(path: path, registry: remote)
|
|
59
|
+
invalidate_pricing_caches!
|
|
60
|
+
end
|
|
59
61
|
refresh_result(
|
|
60
62
|
path: path,
|
|
61
63
|
url: url,
|
|
@@ -67,8 +69,14 @@ module LlmCostTracker
|
|
|
67
69
|
)
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
def invalidate_pricing_caches!
|
|
73
|
+
Pricing::Lookup.reset!
|
|
74
|
+
Pricing::Registry.reset!
|
|
75
|
+
Pricing::ServiceCharges.reset!
|
|
76
|
+
end
|
|
77
|
+
|
|
70
78
|
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
71
|
-
current =
|
|
79
|
+
current = load_registry(path)
|
|
72
80
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
73
81
|
|
|
74
82
|
if response.not_modified
|
|
@@ -82,7 +90,7 @@ module LlmCostTracker
|
|
|
82
90
|
end
|
|
83
91
|
|
|
84
92
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
85
|
-
changes =
|
|
93
|
+
changes = registry_changes(current, remote)
|
|
86
94
|
|
|
87
95
|
CheckResult.new(
|
|
88
96
|
path: path,
|
|
@@ -118,10 +126,15 @@ module LlmCostTracker
|
|
|
118
126
|
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
119
127
|
end
|
|
120
128
|
|
|
121
|
-
|
|
122
|
-
Registry.normalize_price_table(
|
|
129
|
+
raw_models = registry.fetch("models", {})
|
|
130
|
+
models = Registry.normalize_price_table(raw_models).each_with_object({}) do |(model, prices), normalized|
|
|
131
|
+
model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
|
|
132
|
+
normalized[model] = model_metadata.merge(prices.to_h { |key, value| [key.name, value] })
|
|
133
|
+
end
|
|
134
|
+
service_charges = registry["service_charges"]
|
|
135
|
+
ServiceCharges.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
|
|
123
136
|
|
|
124
|
-
|
|
137
|
+
normalized = {
|
|
125
138
|
"metadata" => metadata.merge(
|
|
126
139
|
"schema_version" => schema_version,
|
|
127
140
|
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
@@ -129,11 +142,19 @@ module LlmCostTracker
|
|
|
129
142
|
"source_version" => response.source_version
|
|
130
143
|
),
|
|
131
144
|
"models" => models
|
|
132
|
-
|
|
145
|
+
}
|
|
146
|
+
normalized["service_charges"] = service_charges if service_charges.present?
|
|
147
|
+
normalized
|
|
133
148
|
rescue ArgumentError, TypeError => e
|
|
134
149
|
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
135
150
|
end
|
|
136
151
|
|
|
152
|
+
def load_registry(path)
|
|
153
|
+
YAML.safe_load_file(path, aliases: false) || {}
|
|
154
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
155
|
+
raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
137
158
|
def parse_registry(body)
|
|
138
159
|
registry = JSON.parse(body.to_s)
|
|
139
160
|
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
@@ -148,11 +169,37 @@ module LlmCostTracker
|
|
|
148
169
|
path: path,
|
|
149
170
|
source_url: url,
|
|
150
171
|
source_version: response.source_version,
|
|
151
|
-
changes:
|
|
172
|
+
changes: registry_changes(current, remote),
|
|
152
173
|
written: written,
|
|
153
174
|
not_modified: not_modified
|
|
154
175
|
)
|
|
155
176
|
end
|
|
177
|
+
|
|
178
|
+
def registry_changes(current, remote)
|
|
179
|
+
model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
180
|
+
charge_changes = service_charges_diff(
|
|
181
|
+
current.fetch("service_charges", {}),
|
|
182
|
+
remote.fetch("service_charges", {})
|
|
183
|
+
)
|
|
184
|
+
return model_changes if charge_changes.empty?
|
|
185
|
+
|
|
186
|
+
model_changes.merge("service_charges" => charge_changes)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def service_charges_diff(current, remote)
|
|
190
|
+
(current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
|
|
191
|
+
current_rates = (current[provider] || {}).transform_keys(&:to_s)
|
|
192
|
+
remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
|
|
193
|
+
(current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
|
|
194
|
+
from = current_rates[component]
|
|
195
|
+
to = remote_rates[component]
|
|
196
|
+
next if from == to
|
|
197
|
+
|
|
198
|
+
changes[provider] ||= {}
|
|
199
|
+
changes[provider][component] = { "from" => from, "to" => to }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
156
203
|
end
|
|
157
204
|
end
|
|
158
205
|
end
|