llm_cost_tracker 0.7.0 → 0.7.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 +16 -0
- data/README.md +11 -9
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +28 -35
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
- data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
- data/lib/llm_cost_tracker/parsers/base.rb +10 -19
- data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +52 -11
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
- data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +38 -70
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
|
@@ -1,73 +1,63 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "components"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Pricing
|
|
5
|
-
EffectivePriceSet = Data.define(:input, :cache_read_input, :cache_write_input, :output) do
|
|
6
|
-
def to_h
|
|
7
|
-
{
|
|
8
|
-
input: input,
|
|
9
|
-
cache_read_input: cache_read_input,
|
|
10
|
-
cache_write_input: cache_write_input,
|
|
11
|
-
output: output
|
|
12
|
-
}
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def complete?
|
|
16
|
-
missing_keys.empty?
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def missing_keys
|
|
20
|
-
to_h.filter_map { |key, value| key if value.nil? }
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
7
|
module EffectivePrices
|
|
25
8
|
class << self
|
|
26
9
|
def call(usage:, prices:, pricing_mode:)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
usage.cache_write_input_tokens,
|
|
37
|
-
prices,
|
|
38
|
-
:cache_write_input,
|
|
39
|
-
pricing_mode
|
|
40
|
-
),
|
|
41
|
-
output: price_for_usage(usage.output_tokens, prices, :output, pricing_mode)
|
|
42
|
-
)
|
|
10
|
+
quantities = usage.price_quantities
|
|
11
|
+
context_tier = context_tier?(usage, prices)
|
|
12
|
+
|
|
13
|
+
Pricing::COMPONENTS.to_h do |component|
|
|
14
|
+
price_key = component.price_key
|
|
15
|
+
tokens = quantities.fetch(price_key)
|
|
16
|
+
price = tokens.positive? ? price_for(prices, price_key, pricing_mode, context_tier) : 0.0
|
|
17
|
+
[price_key, price]
|
|
18
|
+
end
|
|
43
19
|
end
|
|
44
20
|
|
|
45
21
|
private
|
|
46
22
|
|
|
47
|
-
def
|
|
48
|
-
|
|
23
|
+
def price_for(prices, key, pricing_mode, context_tier)
|
|
24
|
+
mode = Pricing.normalize_mode(pricing_mode)
|
|
25
|
+
return contextual_price(prices, key, context_tier) unless mode
|
|
49
26
|
|
|
50
|
-
|
|
27
|
+
contextual_price(prices, :"#{mode}_#{key}", context_tier) ||
|
|
28
|
+
derived_batch_price(prices, key, mode, context_tier)
|
|
51
29
|
end
|
|
52
30
|
|
|
53
|
-
def
|
|
54
|
-
|
|
31
|
+
def contextual_price(prices, key, context_tier)
|
|
32
|
+
return prices[key] unless context_tier
|
|
33
|
+
|
|
34
|
+
prices[:"above_context_#{key}"]
|
|
55
35
|
end
|
|
56
36
|
|
|
57
|
-
def
|
|
58
|
-
mode
|
|
59
|
-
return prices[key] unless mode
|
|
37
|
+
def derived_batch_price(prices, key, mode, context_tier)
|
|
38
|
+
return nil unless mode == "batch"
|
|
60
39
|
|
|
61
|
-
|
|
62
|
-
|
|
40
|
+
standard_price = contextual_price(prices, key, context_tier)
|
|
41
|
+
return nil unless standard_price
|
|
42
|
+
|
|
43
|
+
base_key = key == :output ? :output : :input
|
|
44
|
+
batch_key = key == :output ? :batch_output : :batch_input
|
|
45
|
+
base_price = contextual_price(prices, base_key, context_tier)
|
|
46
|
+
batch_price = contextual_price(prices, batch_key, context_tier)
|
|
47
|
+
return nil unless base_price && batch_price
|
|
63
48
|
|
|
64
|
-
|
|
65
|
-
|
|
49
|
+
standard_price * (batch_price.to_f / base_price)
|
|
50
|
+
end
|
|
66
51
|
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
def context_tier?(usage, prices)
|
|
53
|
+
threshold = prices[:_context_price_threshold_tokens]
|
|
54
|
+
return false unless threshold
|
|
69
55
|
|
|
70
|
-
|
|
56
|
+
input_tokens = usage.input_tokens +
|
|
57
|
+
usage.cache_read_input_tokens +
|
|
58
|
+
usage.cache_write_input_tokens +
|
|
59
|
+
usage.cache_write_1h_input_tokens
|
|
60
|
+
input_tokens > threshold.to_i
|
|
71
61
|
end
|
|
72
62
|
end
|
|
73
63
|
end
|
|
@@ -15,9 +15,13 @@ module LlmCostTracker
|
|
|
15
15
|
:effective_prices,
|
|
16
16
|
:missing_price_keys
|
|
17
17
|
) do
|
|
18
|
-
def matched?
|
|
18
|
+
def matched?
|
|
19
|
+
!prices.nil?
|
|
20
|
+
end
|
|
19
21
|
|
|
20
|
-
def complete?
|
|
22
|
+
def complete?
|
|
23
|
+
matched? && missing_price_keys.empty?
|
|
24
|
+
end
|
|
21
25
|
|
|
22
26
|
def message
|
|
23
27
|
return "No price entry matched #{provider}/#{model}" unless matched?
|
|
@@ -29,23 +33,17 @@ module LlmCostTracker
|
|
|
29
33
|
|
|
30
34
|
module Explainer
|
|
31
35
|
class << self
|
|
32
|
-
def call(provider:, model:,
|
|
33
|
-
cache_write_input_tokens: 0, pricing_mode: nil)
|
|
36
|
+
def call(provider:, model:, token_usage:, pricing_mode: nil)
|
|
34
37
|
match = Lookup.call(provider: provider, model: model)
|
|
35
|
-
usage = match && UsageBreakdown.build(
|
|
36
|
-
input_tokens: input_tokens,
|
|
37
|
-
output_tokens: output_tokens,
|
|
38
|
-
cache_read_input_tokens: cache_read_input_tokens,
|
|
39
|
-
cache_write_input_tokens: cache_write_input_tokens
|
|
40
|
-
)
|
|
41
38
|
|
|
42
|
-
explanation(provider, model, pricing_mode, match,
|
|
39
|
+
explanation(provider, model, pricing_mode, match, token_usage)
|
|
43
40
|
end
|
|
44
41
|
|
|
45
42
|
private
|
|
46
43
|
|
|
47
44
|
def explanation(provider, model, pricing_mode, match, usage)
|
|
48
45
|
prices = match&.prices
|
|
46
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode)
|
|
49
47
|
effective = if prices && usage
|
|
50
48
|
EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
|
|
51
49
|
end
|
|
@@ -53,24 +51,15 @@ module LlmCostTracker
|
|
|
53
51
|
Explanation.new(
|
|
54
52
|
provider.to_s,
|
|
55
53
|
model.to_s,
|
|
56
|
-
|
|
54
|
+
pricing_mode,
|
|
57
55
|
match&.source,
|
|
58
56
|
match&.key,
|
|
59
57
|
match&.matched_by,
|
|
60
58
|
prices,
|
|
61
|
-
effective
|
|
62
|
-
effective ? effective.
|
|
59
|
+
effective || {},
|
|
60
|
+
effective ? effective.filter_map { |key, value| key if value.nil? } : []
|
|
63
61
|
)
|
|
64
62
|
end
|
|
65
|
-
|
|
66
|
-
def normalized_pricing_mode(value)
|
|
67
|
-
return nil if value.nil?
|
|
68
|
-
|
|
69
|
-
mode = value.to_s.strip
|
|
70
|
-
return nil if mode.empty? || mode == "standard"
|
|
71
|
-
|
|
72
|
-
mode
|
|
73
|
-
end
|
|
74
63
|
end
|
|
75
64
|
end
|
|
76
65
|
end
|
|
@@ -1,77 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "monitor"
|
|
4
|
-
|
|
5
3
|
module LlmCostTracker
|
|
6
4
|
module Pricing
|
|
7
5
|
module Lookup
|
|
8
6
|
Match = Data.define(:source, :key, :prices, :matched_by)
|
|
9
|
-
MUTEX =
|
|
7
|
+
MUTEX = Mutex.new
|
|
10
8
|
CACHE_MISS = Object.new.freeze
|
|
11
9
|
NO_MATCH = Object.new.freeze
|
|
12
10
|
MAX_LOOKUP_CACHE_ENTRIES = 512
|
|
13
11
|
|
|
14
12
|
class << self
|
|
15
13
|
def call(provider:, model:)
|
|
16
|
-
provider_name = provider.to_s
|
|
14
|
+
provider_name = provider.to_s.presence
|
|
17
15
|
model_name = model.to_s
|
|
18
|
-
|
|
19
|
-
cache_key = [generation, provider_name, model_name]
|
|
16
|
+
cache_key = [provider_name, model_name]
|
|
20
17
|
cached = cached_lookup(cache_key)
|
|
21
18
|
return cached unless cached.equal?(CACHE_MISS)
|
|
22
19
|
|
|
23
|
-
provider_model = provider_name
|
|
20
|
+
provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
|
|
24
21
|
normalized_model = normalize_model_name(model_name)
|
|
25
|
-
current = current_price_tables
|
|
22
|
+
current = current_price_tables
|
|
26
23
|
|
|
27
24
|
match =
|
|
28
25
|
explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
|
|
29
26
|
normalized_model) ||
|
|
30
27
|
explain_table(current.fetch(:file_prices), :prices_file, provider_model, model_name, normalized_model) ||
|
|
31
|
-
explain_table(
|
|
28
|
+
explain_table(Registry.builtin_prices, :bundled, provider_model, model_name, normalized_model)
|
|
32
29
|
cache_lookup(cache_key, match)
|
|
33
30
|
match
|
|
34
31
|
end
|
|
35
32
|
|
|
33
|
+
def reset!
|
|
34
|
+
MUTEX.synchronize do
|
|
35
|
+
@prices_cache = nil
|
|
36
|
+
@lookup_cache = nil
|
|
37
|
+
@sorted_price_keys_cache = nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
36
41
|
private
|
|
37
42
|
|
|
38
|
-
def current_price_tables
|
|
43
|
+
def current_price_tables
|
|
39
44
|
cached = @prices_cache
|
|
40
|
-
return cached
|
|
45
|
+
return cached if cached
|
|
41
46
|
|
|
42
47
|
MUTEX.synchronize do
|
|
43
48
|
cached = @prices_cache
|
|
44
|
-
return cached
|
|
49
|
+
return cached if cached
|
|
45
50
|
|
|
46
51
|
config = LlmCostTracker.configuration
|
|
47
|
-
file_prices =
|
|
48
|
-
overrides =
|
|
52
|
+
file_prices = Registry.file_prices(config.prices_file)
|
|
53
|
+
overrides = Registry.normalize_price_table(config.pricing_overrides)
|
|
49
54
|
value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
|
|
50
|
-
@prices_cache =
|
|
55
|
+
@prices_cache = value
|
|
51
56
|
value
|
|
52
57
|
end
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
def cached_lookup(cache_key)
|
|
56
61
|
cached = @lookup_cache
|
|
57
|
-
return CACHE_MISS unless cached
|
|
58
|
-
return CACHE_MISS unless cached[:values].key?(cache_key)
|
|
62
|
+
return CACHE_MISS unless cached&.key?(cache_key)
|
|
59
63
|
|
|
60
|
-
match = cached
|
|
64
|
+
match = cached.fetch(cache_key)
|
|
61
65
|
match.equal?(NO_MATCH) ? nil : match
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
def cache_lookup(cache_key, match)
|
|
65
69
|
MUTEX.synchronize do
|
|
66
|
-
|
|
67
|
-
values = if cached && cached[:generation] == cache_key.first
|
|
68
|
-
cached[:values].dup
|
|
69
|
-
else
|
|
70
|
-
{}
|
|
71
|
-
end
|
|
70
|
+
values = (@lookup_cache || {}).dup
|
|
72
71
|
values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
|
|
73
72
|
values[cache_key] = match || NO_MATCH
|
|
74
|
-
@lookup_cache =
|
|
73
|
+
@lookup_cache = values.freeze
|
|
75
74
|
end
|
|
76
75
|
end
|
|
77
76
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
require_relative "components"
|
|
7
|
+
require_relative "../logging"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Pricing
|
|
11
|
+
module Registry
|
|
12
|
+
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
13
|
+
EMPTY_PRICES = {}.freeze
|
|
14
|
+
PRICE_KEYS = Pricing::COMPONENTS.map { |component| component.price_key.to_s }.freeze
|
|
15
|
+
METADATA_KEYS = %w[
|
|
16
|
+
_source _source_version _fetched_at _updated _notes _validator_override
|
|
17
|
+
_context_price_threshold_tokens
|
|
18
|
+
].freeze
|
|
19
|
+
MAX_FILE_BYTES = 2_097_152
|
|
20
|
+
MUTEX = Mutex.new
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def builtin_prices
|
|
24
|
+
cached = @builtin_prices
|
|
25
|
+
return cached if cached
|
|
26
|
+
|
|
27
|
+
value = normalize_price_table(raw_registry.fetch("models", {})).freeze
|
|
28
|
+
MUTEX.synchronize { @builtin_prices ||= value }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def metadata
|
|
32
|
+
cached = @metadata
|
|
33
|
+
return cached if cached
|
|
34
|
+
|
|
35
|
+
value = raw_registry.fetch("metadata", {}).freeze
|
|
36
|
+
MUTEX.synchronize { @metadata ||= value }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def file_metadata(path)
|
|
40
|
+
return {} unless path
|
|
41
|
+
|
|
42
|
+
registry = load_price_file(path.to_s)
|
|
43
|
+
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
metadata = registry.fetch("metadata", {})
|
|
46
|
+
raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
metadata
|
|
49
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
50
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_price_table(table)
|
|
54
|
+
normalize_price_entries(table, context: "price table")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def file_prices(path)
|
|
58
|
+
return EMPTY_PRICES unless path
|
|
59
|
+
|
|
60
|
+
path = path.to_s
|
|
61
|
+
cache_key = [path, File.mtime(path).to_f]
|
|
62
|
+
cached = @file_prices_cache
|
|
63
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
64
|
+
|
|
65
|
+
MUTEX.synchronize do
|
|
66
|
+
cached = @file_prices_cache
|
|
67
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
68
|
+
|
|
69
|
+
value = normalize_price_entries(price_file_models(load_price_file(path)), context: path).freeze
|
|
70
|
+
@file_prices_cache = { key: cache_key, value: value }.freeze
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
74
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def raw_registry
|
|
80
|
+
cached = @raw_registry
|
|
81
|
+
return cached if cached
|
|
82
|
+
|
|
83
|
+
MUTEX.synchronize { @raw_registry ||= JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_price_entry(price)
|
|
87
|
+
price.each_with_object({}) do |(key, value), normalized|
|
|
88
|
+
key = key.to_s
|
|
89
|
+
if price_key?(key)
|
|
90
|
+
normalized[key.to_sym] = Float(value)
|
|
91
|
+
elsif key == "_context_price_threshold_tokens"
|
|
92
|
+
normalized[key.to_sym] = Integer(value)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_price_entries(table, context:)
|
|
98
|
+
table = {} if table.nil?
|
|
99
|
+
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
table.each_with_object({}) do |(model, price), normalized|
|
|
102
|
+
price = validate_price_entry(price, model: model, context: context)
|
|
103
|
+
warn_unknown_keys(model, price, context)
|
|
104
|
+
normalized[model.to_s] = normalize_price_entry(price)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def warn_unknown_keys(model, price, path)
|
|
109
|
+
unknown_keys = price.keys.map(&:to_s).reject do |key|
|
|
110
|
+
price_key?(key) || METADATA_KEYS.include?(key)
|
|
111
|
+
end
|
|
112
|
+
return if unknown_keys.empty?
|
|
113
|
+
|
|
114
|
+
Logging.warn(
|
|
115
|
+
"Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
|
|
116
|
+
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def price_key?(key)
|
|
121
|
+
return true if PRICE_KEYS.include?(key)
|
|
122
|
+
|
|
123
|
+
PRICE_KEYS.any? do |base_key|
|
|
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
|
|
130
|
+
|
|
131
|
+
contents = File.read(path)
|
|
132
|
+
return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
|
|
133
|
+
|
|
134
|
+
JSON.parse(contents)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def yaml_file?(path)
|
|
138
|
+
%w[.yaml .yml].include?(File.extname(path).downcase)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def price_file_models(registry)
|
|
142
|
+
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
registry.fetch("models", registry)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_price_entry(price, model:, context:)
|
|
148
|
+
return {} if price.nil?
|
|
149
|
+
return price if price.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module LlmCostTracker
|
|
11
|
+
module Pricing
|
|
12
|
+
module Sync
|
|
13
|
+
class Fetcher
|
|
14
|
+
Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
|
|
15
|
+
def source_version
|
|
16
|
+
etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
USER_AGENT = "llm_cost_tracker price refresh"
|
|
21
|
+
MAX_REDIRECTS = 5
|
|
22
|
+
MAX_BODY_BYTES = 2_097_152
|
|
23
|
+
OPEN_TIMEOUT = 5
|
|
24
|
+
READ_TIMEOUT = 10
|
|
25
|
+
WRITE_TIMEOUT = 10
|
|
26
|
+
|
|
27
|
+
def get(url, etag: nil, redirects: 0)
|
|
28
|
+
raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
|
|
29
|
+
|
|
30
|
+
uri = URI.parse(url)
|
|
31
|
+
raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
|
|
32
|
+
|
|
33
|
+
request = Net::HTTP::Get.new(uri)
|
|
34
|
+
request["User-Agent"] = USER_AGENT
|
|
35
|
+
request["If-None-Match"] = etag if etag
|
|
36
|
+
|
|
37
|
+
response, body = fetch_response(uri, request)
|
|
38
|
+
|
|
39
|
+
case response
|
|
40
|
+
when Net::HTTPSuccess
|
|
41
|
+
build_response(response, body: body || limited_body(response), not_modified: false)
|
|
42
|
+
when Net::HTTPNotModified
|
|
43
|
+
build_response(response, body: nil, not_modified: true)
|
|
44
|
+
when Net::HTTPRedirection
|
|
45
|
+
location = response["location"]
|
|
46
|
+
raise Error, "Redirect without location while fetching #{url}" if location.blank?
|
|
47
|
+
|
|
48
|
+
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
49
|
+
else
|
|
50
|
+
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
51
|
+
end
|
|
52
|
+
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
53
|
+
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def fetch_response(uri, request)
|
|
59
|
+
body = nil
|
|
60
|
+
response = Net::HTTP.start(
|
|
61
|
+
uri.host,
|
|
62
|
+
uri.port,
|
|
63
|
+
use_ssl: uri.scheme == "https",
|
|
64
|
+
open_timeout: OPEN_TIMEOUT,
|
|
65
|
+
read_timeout: READ_TIMEOUT,
|
|
66
|
+
write_timeout: WRITE_TIMEOUT
|
|
67
|
+
) do |http|
|
|
68
|
+
http.request(request) do |streamed_response|
|
|
69
|
+
body = limited_body(streamed_response) if streamed_response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
[response, body]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def limited_body(response)
|
|
77
|
+
body = +""
|
|
78
|
+
if response.respond_to?(:read_body)
|
|
79
|
+
response.read_body do |chunk|
|
|
80
|
+
chunk = chunk.to_s
|
|
81
|
+
if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
|
|
82
|
+
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
body << chunk
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
body = response.body.to_s
|
|
89
|
+
end
|
|
90
|
+
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
|
|
91
|
+
|
|
92
|
+
body
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_response(response, not_modified:, body: response.body)
|
|
96
|
+
Response.new(
|
|
97
|
+
body: body,
|
|
98
|
+
etag: response["etag"],
|
|
99
|
+
last_modified: response["last-modified"],
|
|
100
|
+
not_modified: not_modified,
|
|
101
|
+
fetched_at: Time.now.utc.iso8601
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
module Sync
|
|
6
|
+
module RegistryDiff
|
|
7
|
+
class << self
|
|
8
|
+
def call(current_models, updated_models)
|
|
9
|
+
current_models = normalize_models(current_models)
|
|
10
|
+
updated_models = normalize_models(updated_models)
|
|
11
|
+
|
|
12
|
+
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
13
|
+
fields = price_field_changes(current_models[model], updated_models[model])
|
|
14
|
+
changes[model] = fields if fields.any?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def price_field_changes(current_entry, updated_entry)
|
|
21
|
+
current_price = comparable_price(current_entry)
|
|
22
|
+
updated_price = comparable_price(updated_entry)
|
|
23
|
+
|
|
24
|
+
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
25
|
+
from = current_price[field]
|
|
26
|
+
to = updated_price[field]
|
|
27
|
+
next if from == to
|
|
28
|
+
|
|
29
|
+
changes[field] = { "from" => from, "to" => to }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def comparable_price(entry)
|
|
34
|
+
normalize_hash(entry).slice(*Registry::PRICE_KEYS)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalize_models(models)
|
|
38
|
+
normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
|
|
39
|
+
end
|
|
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
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
require_relative "../registry"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Pricing
|
|
10
|
+
module Sync
|
|
11
|
+
class RegistryLoader
|
|
12
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
13
|
+
|
|
14
|
+
def call(path:, seed_path:)
|
|
15
|
+
source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
|
|
16
|
+
normalize_registry(load_registry_file(source_path))
|
|
17
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
18
|
+
raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def load_registry_file(path)
|
|
24
|
+
if File.size(path) > Registry::MAX_FILE_BYTES
|
|
25
|
+
raise ArgumentError, "pricing registry exceeds #{Registry::MAX_FILE_BYTES} bytes"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
contents = File.read(path)
|
|
29
|
+
registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
|
|
30
|
+
raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
registry
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_registry(registry)
|
|
36
|
+
{
|
|
37
|
+
"metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
|
|
38
|
+
"models" => normalize_models(registry.fetch("models", {}))
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalize_models(models)
|
|
43
|
+
normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
|
|
44
|
+
normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_hash(hash, label:)
|
|
49
|
+
return {} if hash.nil?
|
|
50
|
+
raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
53
|
+
normalized[key.to_s] = value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def yaml_file?(path)
|
|
58
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|