llm_cost_tracker 0.7.2 → 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 +72 -1
- data/README.md +58 -221
- 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 +125 -34
- 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 +4 -10
- 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 +110 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
- 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
|
@@ -7,42 +7,18 @@ module LlmCostTracker
|
|
|
7
7
|
MUTEX = Mutex.new
|
|
8
8
|
CACHE_MISS = Object.new.freeze
|
|
9
9
|
NO_MATCH = Object.new.freeze
|
|
10
|
-
MAX_LOOKUP_CACHE_ENTRIES = 512
|
|
11
10
|
|
|
12
11
|
class << self
|
|
13
12
|
def call(provider:, model:)
|
|
14
13
|
provider_name = provider.to_s.presence
|
|
15
14
|
model_name = model.to_s
|
|
15
|
+
return nil if model_name.empty?
|
|
16
|
+
|
|
16
17
|
cache_key = [provider_name, model_name]
|
|
17
18
|
cached = cached_lookup(cache_key)
|
|
18
19
|
return cached unless cached.equal?(CACHE_MISS)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
normalized_model = normalize_model_name(model_name)
|
|
22
|
-
current = current_price_tables
|
|
23
|
-
|
|
24
|
-
match =
|
|
25
|
-
explain_table(
|
|
26
|
-
table: current.fetch(:pricing_overrides),
|
|
27
|
-
source: :pricing_overrides,
|
|
28
|
-
provider_model: provider_model,
|
|
29
|
-
model_name: model_name,
|
|
30
|
-
normalized_model: normalized_model
|
|
31
|
-
) ||
|
|
32
|
-
explain_table(
|
|
33
|
-
table: current.fetch(:file_prices),
|
|
34
|
-
source: :prices_file,
|
|
35
|
-
provider_model: provider_model,
|
|
36
|
-
model_name: model_name,
|
|
37
|
-
normalized_model: normalized_model
|
|
38
|
-
) ||
|
|
39
|
-
explain_table(
|
|
40
|
-
table: Registry.builtin_prices,
|
|
41
|
-
source: :bundled,
|
|
42
|
-
provider_model: provider_model,
|
|
43
|
-
model_name: model_name,
|
|
44
|
-
normalized_model: normalized_model
|
|
45
|
-
)
|
|
21
|
+
match = lookup_match(provider_name: provider_name, model_name: model_name)
|
|
46
22
|
cache_lookup(cache_key, match)
|
|
47
23
|
match
|
|
48
24
|
end
|
|
@@ -57,6 +33,32 @@ module LlmCostTracker
|
|
|
57
33
|
|
|
58
34
|
private
|
|
59
35
|
|
|
36
|
+
def lookup_match(provider_name:, model_name:)
|
|
37
|
+
provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
|
|
38
|
+
normalized_model = normalize_model_name(model_name)
|
|
39
|
+
current = current_price_tables
|
|
40
|
+
|
|
41
|
+
ordered_table_lookups(current).each do |source, table|
|
|
42
|
+
match = explain_table(
|
|
43
|
+
table: table,
|
|
44
|
+
source: source,
|
|
45
|
+
provider_model: provider_model,
|
|
46
|
+
model_name: model_name,
|
|
47
|
+
normalized_model: normalized_model
|
|
48
|
+
)
|
|
49
|
+
return match if match
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ordered_table_lookups(current)
|
|
55
|
+
[
|
|
56
|
+
[:pricing_overrides, current.fetch(:pricing_overrides)],
|
|
57
|
+
[:prices_file, current.fetch(:file_prices)],
|
|
58
|
+
[:bundled, Registry.builtin_prices]
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
|
|
60
62
|
def current_price_tables
|
|
61
63
|
cached = @prices_cache
|
|
62
64
|
return cached if cached
|
|
@@ -67,8 +69,7 @@ module LlmCostTracker
|
|
|
67
69
|
|
|
68
70
|
config = LlmCostTracker.configuration
|
|
69
71
|
file_prices = Registry.file_prices(config.prices_file)
|
|
70
|
-
|
|
71
|
-
value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
|
|
72
|
+
value = { pricing_overrides: config.pricing_overrides, file_prices: file_prices }.freeze
|
|
72
73
|
@prices_cache = value
|
|
73
74
|
value
|
|
74
75
|
end
|
|
@@ -85,7 +86,6 @@ module LlmCostTracker
|
|
|
85
86
|
def cache_lookup(cache_key, match)
|
|
86
87
|
MUTEX.synchronize do
|
|
87
88
|
values = (@lookup_cache || {}).dup
|
|
88
|
-
values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
|
|
89
89
|
values[cache_key] = match || NO_MATCH
|
|
90
90
|
@lookup_cache = values.freeze
|
|
91
91
|
end
|
|
@@ -135,7 +135,7 @@ module LlmCostTracker
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def match(table:, source:, key:, matched_by:)
|
|
138
|
-
Match.new(source: source
|
|
138
|
+
Match.new(source: source, key: key, prices: table[key], matched_by: matched_by)
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
def snapshot_variant?(model, key)
|
|
@@ -147,14 +147,18 @@ module LlmCostTracker
|
|
|
147
147
|
|
|
148
148
|
def sorted_price_keys(table)
|
|
149
149
|
cached = @sorted_price_keys_cache
|
|
150
|
-
|
|
150
|
+
existing = cached && cached[table]
|
|
151
|
+
return existing if existing
|
|
151
152
|
|
|
152
153
|
MUTEX.synchronize do
|
|
153
154
|
cached = @sorted_price_keys_cache
|
|
154
|
-
|
|
155
|
+
existing = cached && cached[table]
|
|
156
|
+
return existing if existing
|
|
155
157
|
|
|
156
158
|
keys = table.keys.sort_by { |key| -key.length }
|
|
157
|
-
|
|
159
|
+
next_cache = cached ? cached.dup : {}.compare_by_identity
|
|
160
|
+
next_cache[table] = keys
|
|
161
|
+
@sorted_price_keys_cache = next_cache.freeze
|
|
158
162
|
keys
|
|
159
163
|
end
|
|
160
164
|
end
|
|
@@ -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
|