llm_cost_tracker 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +21 -16
- 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 +189 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +33 -36
- 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 +66 -31
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
- 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 +88 -46
- data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
- data/lib/llm_cost_tracker/parsers/base.rb +12 -21
- data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
- data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
- 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 +361 -36
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
- data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
- 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 +159 -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 +39 -69
- 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,69 @@
|
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
10
|
+
quantities = usage.price_quantities
|
|
11
|
+
context_tier = context_tier?(usage: usage, prices: prices)
|
|
12
|
+
|
|
13
|
+
Pricing::COMPONENTS.to_h do |component|
|
|
14
|
+
price_key = component.price_key
|
|
15
|
+
tokens = quantities.fetch(price_key)
|
|
16
|
+
price = if tokens.positive?
|
|
17
|
+
price_for(
|
|
18
|
+
prices: prices,
|
|
19
|
+
key: price_key,
|
|
20
|
+
pricing_mode: pricing_mode,
|
|
21
|
+
context_tier: context_tier
|
|
22
|
+
)
|
|
23
|
+
else
|
|
24
|
+
0.0
|
|
25
|
+
end
|
|
26
|
+
[price_key, price]
|
|
27
|
+
end
|
|
43
28
|
end
|
|
44
29
|
|
|
45
30
|
private
|
|
46
31
|
|
|
47
|
-
def
|
|
48
|
-
|
|
32
|
+
def price_for(prices:, key:, pricing_mode:, context_tier:)
|
|
33
|
+
mode = Pricing.normalize_mode(pricing_mode)
|
|
34
|
+
return contextual_price(prices: prices, key: key, context_tier: context_tier) unless mode
|
|
49
35
|
|
|
50
|
-
|
|
36
|
+
contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier) ||
|
|
37
|
+
derived_mode_price(prices: prices, key: key, mode: mode, context_tier: context_tier)
|
|
51
38
|
end
|
|
52
39
|
|
|
53
|
-
def
|
|
54
|
-
|
|
40
|
+
def contextual_price(prices:, key:, context_tier:)
|
|
41
|
+
return prices[key] unless context_tier
|
|
42
|
+
|
|
43
|
+
prices[:"above_context_#{key}"]
|
|
55
44
|
end
|
|
56
45
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
return
|
|
46
|
+
def derived_mode_price(prices:, key:, mode:, context_tier:)
|
|
47
|
+
standard_price = contextual_price(prices: prices, key: key, context_tier: context_tier)
|
|
48
|
+
return nil unless standard_price
|
|
60
49
|
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
base_key = key == :output ? :output : :input
|
|
51
|
+
base_price = contextual_price(prices: prices, key: base_key, context_tier: context_tier)
|
|
52
|
+
mode_base_price = contextual_price(prices: prices, key: :"#{mode}_#{base_key}", context_tier: context_tier)
|
|
53
|
+
return nil unless base_price && mode_base_price
|
|
63
54
|
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
standard_price * (mode_base_price.to_f / base_price)
|
|
56
|
+
end
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
def context_tier?(usage:, prices:)
|
|
59
|
+
threshold = prices[:_context_price_threshold_tokens]
|
|
60
|
+
return false unless threshold
|
|
69
61
|
|
|
70
|
-
|
|
62
|
+
input_tokens = usage.input_tokens +
|
|
63
|
+
usage.cache_read_input_tokens +
|
|
64
|
+
usage.cache_write_input_tokens +
|
|
65
|
+
usage.cache_write_1h_input_tokens
|
|
66
|
+
input_tokens > threshold.to_i
|
|
71
67
|
end
|
|
72
68
|
end
|
|
73
69
|
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,48 +33,39 @@ 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(
|
|
39
|
+
explanation(
|
|
40
|
+
provider: provider,
|
|
41
|
+
model: model,
|
|
42
|
+
pricing_mode: pricing_mode,
|
|
43
|
+
match: match,
|
|
44
|
+
usage: token_usage
|
|
45
|
+
)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
private
|
|
46
49
|
|
|
47
|
-
def explanation(provider
|
|
50
|
+
def explanation(provider:, model:, pricing_mode:, match:, usage:)
|
|
48
51
|
prices = match&.prices
|
|
52
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode)
|
|
49
53
|
effective = if prices && usage
|
|
50
54
|
EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
Explanation.new(
|
|
54
|
-
provider.to_s,
|
|
55
|
-
model.to_s,
|
|
56
|
-
|
|
57
|
-
match&.source,
|
|
58
|
-
match&.key,
|
|
59
|
-
match&.matched_by,
|
|
60
|
-
prices,
|
|
61
|
-
|
|
62
|
-
effective ? effective.
|
|
58
|
+
provider: provider.to_s,
|
|
59
|
+
model: model.to_s,
|
|
60
|
+
pricing_mode: pricing_mode,
|
|
61
|
+
source: match&.source,
|
|
62
|
+
matched_key: match&.key,
|
|
63
|
+
matched_by: match&.matched_by,
|
|
64
|
+
prices: prices,
|
|
65
|
+
effective_prices: effective || {},
|
|
66
|
+
missing_price_keys: effective ? effective.filter_map { |key, value| key if value.nil? } : []
|
|
63
67
|
)
|
|
64
68
|
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
69
|
end
|
|
75
70
|
end
|
|
76
71
|
end
|
|
@@ -1,120 +1,141 @@
|
|
|
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
|
-
explain_table(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
)
|
|
32
46
|
cache_lookup(cache_key, match)
|
|
33
47
|
match
|
|
34
48
|
end
|
|
35
49
|
|
|
50
|
+
def reset!
|
|
51
|
+
MUTEX.synchronize do
|
|
52
|
+
@prices_cache = nil
|
|
53
|
+
@lookup_cache = nil
|
|
54
|
+
@sorted_price_keys_cache = nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
36
58
|
private
|
|
37
59
|
|
|
38
|
-
def current_price_tables
|
|
60
|
+
def current_price_tables
|
|
39
61
|
cached = @prices_cache
|
|
40
|
-
return cached
|
|
62
|
+
return cached if cached
|
|
41
63
|
|
|
42
64
|
MUTEX.synchronize do
|
|
43
65
|
cached = @prices_cache
|
|
44
|
-
return cached
|
|
66
|
+
return cached if cached
|
|
45
67
|
|
|
46
68
|
config = LlmCostTracker.configuration
|
|
47
|
-
file_prices =
|
|
48
|
-
overrides =
|
|
69
|
+
file_prices = Registry.file_prices(config.prices_file)
|
|
70
|
+
overrides = Registry.normalize_price_table(config.pricing_overrides)
|
|
49
71
|
value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
|
|
50
|
-
@prices_cache =
|
|
72
|
+
@prices_cache = value
|
|
51
73
|
value
|
|
52
74
|
end
|
|
53
75
|
end
|
|
54
76
|
|
|
55
77
|
def cached_lookup(cache_key)
|
|
56
78
|
cached = @lookup_cache
|
|
57
|
-
return CACHE_MISS unless cached
|
|
58
|
-
return CACHE_MISS unless cached[:values].key?(cache_key)
|
|
79
|
+
return CACHE_MISS unless cached&.key?(cache_key)
|
|
59
80
|
|
|
60
|
-
match = cached
|
|
81
|
+
match = cached.fetch(cache_key)
|
|
61
82
|
match.equal?(NO_MATCH) ? nil : match
|
|
62
83
|
end
|
|
63
84
|
|
|
64
85
|
def cache_lookup(cache_key, match)
|
|
65
86
|
MUTEX.synchronize do
|
|
66
|
-
|
|
67
|
-
values = if cached && cached[:generation] == cache_key.first
|
|
68
|
-
cached[:values].dup
|
|
69
|
-
else
|
|
70
|
-
{}
|
|
71
|
-
end
|
|
87
|
+
values = (@lookup_cache || {}).dup
|
|
72
88
|
values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
|
|
73
89
|
values[cache_key] = match || NO_MATCH
|
|
74
|
-
@lookup_cache =
|
|
90
|
+
@lookup_cache = values.freeze
|
|
75
91
|
end
|
|
76
92
|
end
|
|
77
93
|
|
|
78
|
-
def explain_table(table
|
|
94
|
+
def explain_table(table:, source:, provider_model:, model_name:, normalized_model:)
|
|
79
95
|
return nil if table.empty?
|
|
80
96
|
|
|
81
|
-
direct_match(table, source, provider_model, :provider_model) ||
|
|
82
|
-
direct_match(table, source, model_name, :model) ||
|
|
83
|
-
direct_match(table, source, normalized_model, :normalized_model) ||
|
|
84
|
-
unique_providerless_lookup(normalized_model, table, source) ||
|
|
85
|
-
fuzzy_match(provider_model, normalized_model, table, source) ||
|
|
86
|
-
unique_providerless_fuzzy_match(normalized_model, table, source)
|
|
97
|
+
direct_match(table: table, source: source, key: provider_model, matched_by: :provider_model) ||
|
|
98
|
+
direct_match(table: table, source: source, key: model_name, matched_by: :model) ||
|
|
99
|
+
direct_match(table: table, source: source, key: normalized_model, matched_by: :normalized_model) ||
|
|
100
|
+
unique_providerless_lookup(model: normalized_model, table: table, source: source) ||
|
|
101
|
+
fuzzy_match(model: provider_model, normalized_model: normalized_model, table: table, source: source) ||
|
|
102
|
+
unique_providerless_fuzzy_match(model: normalized_model, table: table, source: source)
|
|
87
103
|
end
|
|
88
104
|
|
|
89
105
|
def normalize_model_name(model)
|
|
90
106
|
model.to_s.split("/").last
|
|
91
107
|
end
|
|
92
108
|
|
|
93
|
-
def unique_providerless_lookup(model
|
|
109
|
+
def unique_providerless_lookup(model:, table:, source:)
|
|
94
110
|
matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
|
|
95
|
-
|
|
111
|
+
return unless matches.one?
|
|
112
|
+
|
|
113
|
+
match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_model)
|
|
96
114
|
end
|
|
97
115
|
|
|
98
|
-
def fuzzy_match(model
|
|
116
|
+
def fuzzy_match(model:, normalized_model:, table:, source:)
|
|
99
117
|
sorted_price_keys(table).each do |key|
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
|
|
119
|
+
return match(table: table, source: source, key: key, matched_by: :dated_snapshot)
|
|
120
|
+
end
|
|
102
121
|
end
|
|
103
122
|
|
|
104
123
|
nil
|
|
105
124
|
end
|
|
106
125
|
|
|
107
|
-
def unique_providerless_fuzzy_match(model
|
|
126
|
+
def unique_providerless_fuzzy_match(model:, table:, source:)
|
|
108
127
|
matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
|
|
109
|
-
|
|
128
|
+
return unless matches.one?
|
|
129
|
+
|
|
130
|
+
match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_dated_snapshot)
|
|
110
131
|
end
|
|
111
132
|
|
|
112
|
-
def direct_match(table
|
|
113
|
-
match(table, source, key, matched_by) if table.key?(key)
|
|
133
|
+
def direct_match(table:, source:, key:, matched_by:)
|
|
134
|
+
match(table: table, source: source, key: key, matched_by: matched_by) if table.key?(key)
|
|
114
135
|
end
|
|
115
136
|
|
|
116
|
-
def match(table
|
|
117
|
-
Match.new(source.to_s, key, table[key], matched_by.to_s)
|
|
137
|
+
def match(table:, source:, key:, matched_by:)
|
|
138
|
+
Match.new(source: source.to_s, key: key, prices: table[key], matched_by: matched_by.to_s)
|
|
118
139
|
end
|
|
119
140
|
|
|
120
141
|
def snapshot_variant?(model, key)
|
|
@@ -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
|