llm_cost_tracker 0.14.1 → 0.14.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 +82 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
- data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
- data/lib/llm_cost_tracker/integrations/base.rb +39 -18
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
- data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/store.rb +2 -2
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
- data/lib/llm_cost_tracker/parsers.rb +7 -40
- data/lib/llm_cost_tracker/prices.json +2237 -154
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tracker.rb +17 -9
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/tasks/llm_cost_tracker.rake +24 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +8 -7
|
@@ -13,9 +13,12 @@ module LlmCostTracker
|
|
|
13
13
|
RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
|
|
14
14
|
|
|
15
15
|
DEFAULT_BATCH_SIZE = 500
|
|
16
|
+
REGISTRY_SOURCES = %w[pricing_overrides prices_file bundled].freeze
|
|
17
|
+
RATE_FIELDS = %i[rate_amount rate_quantity cost cost_status price_key price_source price_source_version].freeze
|
|
18
|
+
private_constant :REGISTRY_SOURCES, :RATE_FIELDS
|
|
16
19
|
|
|
17
20
|
class << self
|
|
18
|
-
def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE)
|
|
21
|
+
def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE, reprice: false)
|
|
19
22
|
examined = 0
|
|
20
23
|
recomputed = 0
|
|
21
24
|
|
|
@@ -24,11 +27,11 @@ module LlmCostTracker
|
|
|
24
27
|
LlmCostTracker::Call.transaction do
|
|
25
28
|
batch.each do |call|
|
|
26
29
|
examined += 1
|
|
27
|
-
calculation = recompute_for(call)
|
|
30
|
+
calculation = recompute_for(call, reprice: reprice)
|
|
28
31
|
next unless calculation
|
|
29
32
|
|
|
30
|
-
persist!(call, calculation)
|
|
31
33
|
rollup_events << rollup_event_for(call, calculation)
|
|
34
|
+
persist!(call, calculation)
|
|
32
35
|
recomputed += 1
|
|
33
36
|
end
|
|
34
37
|
Ledger::Rollups.increment!(rollup_events)
|
|
@@ -39,21 +42,46 @@ module LlmCostTracker
|
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
def default_scope
|
|
42
|
-
LlmCostTracker::Call.
|
|
45
|
+
with_known_usage(LlmCostTracker::Call.unknown_pricing)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reprice_scope(range)
|
|
49
|
+
with_known_usage(LlmCostTracker::Call.where(tracked_at: range))
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
private
|
|
46
53
|
|
|
47
|
-
def
|
|
54
|
+
def with_known_usage(calls)
|
|
55
|
+
calls.where(usage_source: nil).or(calls.where.not(usage_source: Usage::Source::UNKNOWN))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def recompute_for(call, reprice:)
|
|
48
59
|
calculation = Pricing::Calculation.for(
|
|
49
60
|
provider: call.provider,
|
|
50
61
|
model: call.model,
|
|
51
62
|
tokens: token_usage_from(call),
|
|
52
|
-
line_items:
|
|
53
|
-
pricing_mode: call.pricing_mode,
|
|
54
|
-
usage_source: call.usage_source
|
|
63
|
+
line_items: line_items_from(call, reprice: reprice),
|
|
64
|
+
pricing_mode: call.pricing_mode || bedrock_pricing_mode(call),
|
|
65
|
+
usage_source: call.usage_source,
|
|
66
|
+
at: call.tracked_at
|
|
55
67
|
)
|
|
56
|
-
|
|
68
|
+
return unless calculation.token_cost || provider_billed?(calculation)
|
|
69
|
+
return if [calculation.cost.total, calculation.cost_status] == [call.total_cost, call.cost_status]
|
|
70
|
+
return calculation if reprice
|
|
71
|
+
|
|
72
|
+
rates = calculation.priced_line_items.to_h { |item| [dimension_key(item), item.rate_amount] }
|
|
73
|
+
recorded = call.line_items.select { |record| record.unit == "token" && record.rate_amount }
|
|
74
|
+
calculation if recorded.all? { |record| record.rate_amount == rates[dimension_key(record)] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def provider_billed?(calculation)
|
|
78
|
+
calculation.priced_line_items.any? { |item| item.kind == "billed_request" && item.priced? }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def bedrock_pricing_mode(call)
|
|
82
|
+
return unless call.provider == "bedrock"
|
|
83
|
+
|
|
84
|
+
Providers::Anthropic::UsageExtractor.pricing_mode(request: { model: call.model }, usage: {})
|
|
57
85
|
end
|
|
58
86
|
|
|
59
87
|
def persist!(call, calculation)
|
|
@@ -103,7 +131,7 @@ module LlmCostTracker
|
|
|
103
131
|
provider: call.provider,
|
|
104
132
|
tracked_at: call.tracked_at,
|
|
105
133
|
pricing_snapshot: calculation.snapshot,
|
|
106
|
-
total_cost: calculation.cost.total
|
|
134
|
+
total_cost: calculation.cost.total - call.total_cost.to_d
|
|
107
135
|
)
|
|
108
136
|
end
|
|
109
137
|
|
|
@@ -111,11 +139,16 @@ module LlmCostTracker
|
|
|
111
139
|
Usage::TokenUsage.build(**call.attributes.transform_keys(&:to_sym).slice(*Usage::TokenUsage.members))
|
|
112
140
|
end
|
|
113
141
|
|
|
114
|
-
def
|
|
115
|
-
call.line_items.
|
|
116
|
-
|
|
142
|
+
def line_items_from(call, reprice:)
|
|
143
|
+
call.line_items.sort_by(&:position).map do |record|
|
|
144
|
+
attributes = record.attributes.transform_keys(&:to_sym).slice(*Charges::LineItem.members)
|
|
145
|
+
Charges::LineItem.build(reprice ? at_current_rates(record, attributes) : attributes)
|
|
117
146
|
end
|
|
118
147
|
end
|
|
148
|
+
|
|
149
|
+
def at_current_rates(record, attributes)
|
|
150
|
+
REGISTRY_SOURCES.include?(record.price_source) ? attributes.except(*RATE_FIELDS) : attributes
|
|
151
|
+
end
|
|
119
152
|
end
|
|
120
153
|
end
|
|
121
154
|
end
|
|
@@ -11,24 +11,27 @@ module LlmCostTracker
|
|
|
11
11
|
class Calculation
|
|
12
12
|
RATE_DENOMINATOR_TOKENS = Pricing::RATE_BASIS_QUANTITIES.fetch("per_million_tokens")
|
|
13
13
|
SNAPSHOT_SCHEMA_VERSION = 1
|
|
14
|
-
|
|
14
|
+
CACHE_INPUT_KEYS = %w[cache_read_input cache_write_input].freeze
|
|
15
|
+
private_constant :RATE_DENOMINATOR_TOKENS, :SNAPSHOT_SCHEMA_VERSION, :CACHE_INPUT_KEYS
|
|
15
16
|
|
|
16
|
-
def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil)
|
|
17
|
+
def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil, at: Time.now)
|
|
17
18
|
new(provider: provider,
|
|
18
19
|
model: model,
|
|
19
20
|
token_usage: Usage::TokenUsage.build_from_tokens(tokens),
|
|
20
21
|
line_items: line_items,
|
|
21
22
|
mode: Mode.normalize(pricing_mode),
|
|
22
|
-
usage_source: usage_source
|
|
23
|
+
usage_source: usage_source,
|
|
24
|
+
at: at)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil)
|
|
27
|
+
def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil, at: Time.now)
|
|
26
28
|
@provider = provider
|
|
27
29
|
@model = model
|
|
28
30
|
@token_usage = token_usage
|
|
29
31
|
@line_items = line_items
|
|
30
32
|
@mode = mode
|
|
31
33
|
@usage_source = usage_source
|
|
34
|
+
@at = at
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
attr_reader :mode
|
|
@@ -36,21 +39,26 @@ module LlmCostTracker
|
|
|
36
39
|
def match
|
|
37
40
|
return @match if defined?(@match)
|
|
38
41
|
|
|
39
|
-
@match = Matcher.lookup(provider: @provider, model: @model)
|
|
42
|
+
@match = Matcher.lookup(provider: @provider, model: @model, at: @at)
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def effective
|
|
43
46
|
return @effective if defined?(@effective)
|
|
44
47
|
|
|
45
48
|
@effective = match && EffectivePrices.call(
|
|
46
|
-
usage: @token_usage,
|
|
49
|
+
usage: @token_usage,
|
|
50
|
+
quantities: quantities,
|
|
51
|
+
prices: match.prices,
|
|
52
|
+
pricing_mode: @mode,
|
|
53
|
+
cache_at_input_rate: cache_keys_at_input_rate
|
|
47
54
|
)
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
def token_cost
|
|
51
58
|
return @token_cost if defined?(@token_cost)
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
known = priceable? && @usage_source != Usage::Source::UNKNOWN && !only_unpriced_lines?
|
|
61
|
+
@token_cost = known ? build_token_cost : nil
|
|
54
62
|
end
|
|
55
63
|
|
|
56
64
|
def priced_line_items
|
|
@@ -77,39 +85,73 @@ module LlmCostTracker
|
|
|
77
85
|
end
|
|
78
86
|
|
|
79
87
|
def cost_status
|
|
80
|
-
@cost_status ||=
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
@cost_status ||= begin
|
|
89
|
+
status = billed_status || Charges::CostStatus.call(
|
|
90
|
+
token_usage: @token_usage,
|
|
91
|
+
usage_source: @usage_source,
|
|
92
|
+
token_cost: token_cost,
|
|
93
|
+
token_pricing_partial: token_pricing_partial?,
|
|
94
|
+
service_line_items: priced_line_items.reject(&:token?),
|
|
95
|
+
total_cost: cost&.total
|
|
96
|
+
)
|
|
97
|
+
@partial_iteration && status != Charges::CostStatus::UNKNOWN ? Charges::CostStatus::PARTIAL : status
|
|
98
|
+
end
|
|
88
99
|
end
|
|
89
100
|
|
|
90
101
|
private
|
|
91
102
|
|
|
103
|
+
def cache_keys_at_input_rate
|
|
104
|
+
return [] if match.source.name == "pricing_overrides" || !match.key.start_with?("openai/")
|
|
105
|
+
|
|
106
|
+
listed = Registry.builtin_prices[match.key] || (match.prices unless manual_file_entry?)
|
|
107
|
+
listed ? CACHE_INPUT_KEYS - listed.keys : []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def manual_file_entry?
|
|
111
|
+
entry = Registry.raw_file_registry(LlmCostTracker.configuration.pricing.file).dig("models", match.key)
|
|
112
|
+
entry.is_a?(Hash) && entry["_source"].to_s == "manual"
|
|
113
|
+
end
|
|
114
|
+
|
|
92
115
|
def quantities
|
|
93
|
-
@quantities ||= @token_usage.priced_quantities
|
|
116
|
+
@quantities ||= @line_items.each_with_object(@token_usage.priced_quantities) do |line_item, result|
|
|
117
|
+
dimension = line_item.dimension
|
|
118
|
+
next unless dimension&.parent
|
|
119
|
+
|
|
120
|
+
quantity = [line_item.quantity.to_i, result.fetch(dimension.parent)].min
|
|
121
|
+
result[dimension.parent] -= quantity
|
|
122
|
+
result[dimension.key] = result.fetch(dimension.key, 0) + quantity
|
|
123
|
+
end
|
|
94
124
|
end
|
|
95
125
|
|
|
96
126
|
def unpriced_line_items
|
|
97
|
-
Charges::LineItem.
|
|
127
|
+
Charges::LineItem.from_quantities(quantities) + @line_items.reject(&:token?)
|
|
98
128
|
end
|
|
99
129
|
|
|
100
130
|
def priceable?
|
|
101
|
-
!match.nil? && !all_billable_unpriced?
|
|
131
|
+
!match.nil? && billed_line.nil? && !all_billable_unpriced?
|
|
102
132
|
end
|
|
103
133
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
next unless quantity.positive?
|
|
108
|
-
return false if effective[key]
|
|
134
|
+
def billed_line
|
|
135
|
+
@line_items.find { |line_item| line_item.kind == "billed_request" }
|
|
136
|
+
end
|
|
109
137
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
138
|
+
def billed_status
|
|
139
|
+
return unless billed_line
|
|
140
|
+
|
|
141
|
+
unpriced_attempt = priced_line_items.any? { |item| item.kind == "model_iteration" && item.unpriced? }
|
|
142
|
+
return Charges::CostStatus::PARTIAL if unpriced_attempt
|
|
143
|
+
|
|
144
|
+
billed_line.priced? && cost.total.positive? ? Charges::CostStatus::COMPLETE : billed_line.cost_status
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def only_unpriced_lines?
|
|
148
|
+
billable = priced_line_items.select(&:billable?)
|
|
149
|
+
billable.any? && billable.none?(&:priced?)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def all_billable_unpriced?
|
|
153
|
+
billable = quantities.select { |_key, quantity| quantity.positive? }.keys
|
|
154
|
+
billable.any? && billable.none? { |key| effective[key] }
|
|
113
155
|
end
|
|
114
156
|
|
|
115
157
|
def priced_token_line_items
|
|
@@ -117,10 +159,12 @@ module LlmCostTracker
|
|
|
117
159
|
end
|
|
118
160
|
|
|
119
161
|
def build_token_cost
|
|
120
|
-
|
|
162
|
+
by_component = priced_token_line_items.group_by { |item| item.dimension.parent || item.dimension.key }
|
|
121
163
|
components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
line_items = by_component.fetch(dimension.key, [])
|
|
165
|
+
next if line_items.any?(&:unpriced?)
|
|
166
|
+
|
|
167
|
+
result[dimension.cost_key] = line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) }
|
|
124
168
|
end
|
|
125
169
|
Charges::Cost.new(
|
|
126
170
|
components: components.freeze,
|
|
@@ -129,12 +173,6 @@ module LlmCostTracker
|
|
|
129
173
|
)
|
|
130
174
|
end
|
|
131
175
|
|
|
132
|
-
def token_dimension_cost(dimension, line_item)
|
|
133
|
-
return BigDecimal("0") if quantities[dimension.key].zero?
|
|
134
|
-
|
|
135
|
-
line_item&.cost
|
|
136
|
-
end
|
|
137
|
-
|
|
138
176
|
def build_snapshot
|
|
139
177
|
{
|
|
140
178
|
"schema_version" => SNAPSHOT_SCHEMA_VERSION,
|
|
@@ -143,7 +181,7 @@ module LlmCostTracker
|
|
|
143
181
|
"source_version" => match.source.version,
|
|
144
182
|
"matched_by" => match.matched_by.to_s,
|
|
145
183
|
"currency" => match.source.currency,
|
|
146
|
-
"rates" =>
|
|
184
|
+
"rates" => charge_rates(kept_service_lines).merge(charge_rates(priced_token_line_items))
|
|
147
185
|
}
|
|
148
186
|
end
|
|
149
187
|
|
|
@@ -155,20 +193,12 @@ module LlmCostTracker
|
|
|
155
193
|
"source_version" => primary.price_source_version,
|
|
156
194
|
"matched_by" => "service_charges",
|
|
157
195
|
"currency" => cost.currency,
|
|
158
|
-
"rates" =>
|
|
196
|
+
"rates" => charge_rates(kept_service_lines)
|
|
159
197
|
}
|
|
160
198
|
end
|
|
161
199
|
|
|
162
|
-
def
|
|
163
|
-
|
|
164
|
-
next if line_item.price_key.nil? || line_item.rate_amount.nil?
|
|
165
|
-
|
|
166
|
-
rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def service_charge_rates
|
|
171
|
-
kept_service_lines.each_with_object({}) do |line_item, rates|
|
|
200
|
+
def charge_rates(line_items)
|
|
201
|
+
line_items.each_with_object({}) do |line_item, rates|
|
|
172
202
|
next if line_item.price_key.nil? || line_item.rate_amount.nil?
|
|
173
203
|
|
|
174
204
|
rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
|
|
@@ -180,14 +210,8 @@ module LlmCostTracker
|
|
|
180
210
|
end
|
|
181
211
|
|
|
182
212
|
def price_token(line_item)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) unless priceable?
|
|
186
|
-
|
|
187
|
-
price = effective[dimension.key]
|
|
188
|
-
return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
|
|
189
|
-
|
|
190
|
-
line_item.with_rate(token_rate(price))
|
|
213
|
+
price = priceable? && effective[line_item.dimension.key]
|
|
214
|
+
price ? line_item.with_rate(token_rate(price)) : line_item
|
|
191
215
|
end
|
|
192
216
|
|
|
193
217
|
def token_rate(price)
|
|
@@ -202,7 +226,8 @@ module LlmCostTracker
|
|
|
202
226
|
end
|
|
203
227
|
|
|
204
228
|
def price_service(line_item)
|
|
205
|
-
return line_item if line_item.
|
|
229
|
+
return price_iteration(line_item) if line_item.kind == "model_iteration"
|
|
230
|
+
return line_item if line_item.priced? || !line_item.billable? || billed_line
|
|
206
231
|
|
|
207
232
|
rate = model_rate(line_item) ||
|
|
208
233
|
ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: @mode)
|
|
@@ -211,19 +236,45 @@ module LlmCostTracker
|
|
|
211
236
|
line_item.with_rate(rate)
|
|
212
237
|
end
|
|
213
238
|
|
|
239
|
+
def price_iteration(line_item)
|
|
240
|
+
details = line_item.details.to_h.transform_keys(&:to_sym)
|
|
241
|
+
model = details[:model].to_s
|
|
242
|
+
calculation = Calculation.for(
|
|
243
|
+
provider: @provider,
|
|
244
|
+
model: model,
|
|
245
|
+
tokens: details.slice(*Usage::TokenUsage.members),
|
|
246
|
+
pricing_mode: iteration_mode(model),
|
|
247
|
+
at: @at
|
|
248
|
+
)
|
|
249
|
+
cost = calculation.token_cost
|
|
250
|
+
return line_item unless cost
|
|
251
|
+
|
|
252
|
+
@partial_iteration ||= calculation.cost_status == Charges::CostStatus::PARTIAL
|
|
253
|
+
status = cost.total.zero? ? Charges::CostStatus::FREE : Charges::CostStatus::COMPLETE
|
|
254
|
+
line_item.with(rate_amount: cost.total, cost: cost.total, currency: cost.currency, cost_status: status)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def iteration_mode(model)
|
|
258
|
+
return @mode if Matcher.modifier_priced?(provider: @provider, model: model, modifier: "fast")
|
|
259
|
+
|
|
260
|
+
Mode.compose(Mode.tokenize(@mode) - ["fast"])
|
|
261
|
+
end
|
|
262
|
+
|
|
214
263
|
def model_rate(line_item)
|
|
215
264
|
return nil unless priceable?
|
|
216
265
|
|
|
217
|
-
|
|
218
|
-
|
|
266
|
+
modes = @mode ? Mode.permutations_for(@mode) : []
|
|
267
|
+
key = [*modes.map { |mode| PriceKey.build(line_item.kind, mode: mode) }, line_item.kind]
|
|
268
|
+
.find { |candidate| match.prices[candidate].is_a?(Numeric) }
|
|
269
|
+
return nil unless key
|
|
219
270
|
|
|
220
271
|
dimension = Usage::Catalog[line_item.kind]
|
|
221
272
|
Pricing::Rate.new(
|
|
222
|
-
amount:
|
|
273
|
+
amount: match.prices[key].to_d,
|
|
223
274
|
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
224
275
|
currency: match.source.currency,
|
|
225
276
|
source: match.source.name,
|
|
226
|
-
source_key: "#{match.key}.#{
|
|
277
|
+
source_key: "#{match.key}.#{key}",
|
|
227
278
|
source_version: match.source.version
|
|
228
279
|
)
|
|
229
280
|
end
|
|
@@ -231,17 +282,13 @@ module LlmCostTracker
|
|
|
231
282
|
def kept_service_lines
|
|
232
283
|
return @kept_service_lines if defined?(@kept_service_lines)
|
|
233
284
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
242
|
-
matching
|
|
243
|
-
end
|
|
244
|
-
end
|
|
285
|
+
priced_services = priced_line_items.reject(&:token?).select(&:priced?)
|
|
286
|
+
return @kept_service_lines = [] if priced_services.empty?
|
|
287
|
+
|
|
288
|
+
base_currency = token_cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
|
|
289
|
+
matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
|
|
290
|
+
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
291
|
+
@kept_service_lines = matching
|
|
245
292
|
end
|
|
246
293
|
|
|
247
294
|
def combine_service_lines
|
|
@@ -256,10 +303,6 @@ module LlmCostTracker
|
|
|
256
303
|
)
|
|
257
304
|
end
|
|
258
305
|
|
|
259
|
-
def base_currency_for(cost, priced_services)
|
|
260
|
-
cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
|
|
261
|
-
end
|
|
262
|
-
|
|
263
306
|
def warn_currency_mismatch(lines, base_currency)
|
|
264
307
|
currencies = lines.map { |line| line.currency.to_s }.uniq.sort
|
|
265
308
|
Logging.warn(
|
|
@@ -11,18 +11,14 @@ module LlmCostTracker
|
|
|
11
11
|
Resolved = Data.define(:amount, :key)
|
|
12
12
|
|
|
13
13
|
class << self
|
|
14
|
-
def call(usage:, quantities:, prices:, pricing_mode:)
|
|
14
|
+
def call(usage:, quantities:, prices:, pricing_mode:, cache_at_input_rate: [])
|
|
15
15
|
context_tier = context_tier?(usage: usage, prices: prices)
|
|
16
16
|
orderings = pricing_mode && Mode.permutations_for(pricing_mode)
|
|
17
|
+
lookup = { prices: prices, orderings: orderings, context_tier: context_tier }
|
|
17
18
|
|
|
18
19
|
quantities.to_h do |price_key, tokens|
|
|
19
20
|
resolved = if tokens.positive?
|
|
20
|
-
|
|
21
|
-
prices: prices,
|
|
22
|
-
key: price_key,
|
|
23
|
-
orderings: orderings,
|
|
24
|
-
context_tier: context_tier
|
|
25
|
-
)
|
|
21
|
+
price_or_fallback(price_key, lookup, cache_at_input_rate)
|
|
26
22
|
else
|
|
27
23
|
Resolved.new(amount: BigDecimal("0"), key: price_key)
|
|
28
24
|
end
|
|
@@ -32,6 +28,11 @@ module LlmCostTracker
|
|
|
32
28
|
|
|
33
29
|
private
|
|
34
30
|
|
|
31
|
+
def price_or_fallback(key, lookup, cache_at_input_rate)
|
|
32
|
+
fallback = Usage::Catalog.fetch(key).parent || ("input" if cache_at_input_rate.include?(key))
|
|
33
|
+
price_for(key: key, **lookup) || (fallback && price_or_fallback(fallback, lookup, cache_at_input_rate))
|
|
34
|
+
end
|
|
35
|
+
|
|
35
36
|
def price_for(prices:, key:, orderings:, context_tier:)
|
|
36
37
|
unless orderings
|
|
37
38
|
standard = PriceKey.build(key, above_context: context_tier)
|
|
@@ -45,8 +46,7 @@ module LlmCostTracker
|
|
|
45
46
|
end
|
|
46
47
|
return nil if %w[input output].include?(key)
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
resolve(derived, nil)
|
|
49
|
+
derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def resolve(amount, key)
|
|
@@ -63,7 +63,8 @@ module LlmCostTracker
|
|
|
63
63
|
mode_base_price = prices[PriceKey.build("input", mode: mode, above_context: context_tier)]
|
|
64
64
|
next unless mode_base_price
|
|
65
65
|
|
|
66
|
-
return standard_price.to_d * mode_base_price.to_d / base_price.to_d
|
|
66
|
+
return resolve(standard_price.to_d * mode_base_price.to_d / base_price.to_d,
|
|
67
|
+
PriceKey.build(key, mode: mode, above_context: context_tier))
|
|
67
68
|
end
|
|
68
69
|
nil
|
|
69
70
|
end
|
|
@@ -22,8 +22,11 @@ module LlmCostTracker
|
|
|
22
22
|
|
|
23
23
|
def self.char_count(value)
|
|
24
24
|
case value
|
|
25
|
-
when String then value.length
|
|
26
|
-
when Hash
|
|
25
|
+
when String then value.match?(/\Adata:[^,]*;base64,/) ? 0 : value.length
|
|
26
|
+
when Hash
|
|
27
|
+
return 0 if value["type"].to_s == "base64"
|
|
28
|
+
|
|
29
|
+
value.except("input_audio", "inline_data", "inlineData").values.sum { |nested| char_count(nested) }
|
|
27
30
|
when Array then value.sum { |nested| char_count(nested) }
|
|
28
31
|
else 0
|
|
29
32
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "date"
|
|
4
5
|
|
|
5
6
|
require_relative "registry"
|
|
6
7
|
|
|
@@ -10,21 +11,37 @@ module LlmCostTracker
|
|
|
10
11
|
Match = Data.define(:source, :key, :prices, :matched_by)
|
|
11
12
|
|
|
12
13
|
CACHE_LIMIT = 2048
|
|
13
|
-
|
|
14
|
+
BEDROCK_ANTHROPIC_ID = /\A(?:[a-z]+\.)?anthropic\.(claude-.+?)(?:-v\d+(?::\d+)?)?\z/
|
|
15
|
+
private_constant :CACHE_LIMIT, :BEDROCK_ANTHROPIC_ID
|
|
14
16
|
|
|
15
17
|
class << self
|
|
16
|
-
def lookup(provider:, model:)
|
|
18
|
+
def lookup(provider:, model:, at: Time.now)
|
|
17
19
|
provider_name = provider.to_s.presence
|
|
18
20
|
model_name = model.to_s
|
|
19
21
|
return nil if model_name.empty?
|
|
20
22
|
|
|
21
23
|
sources = Registry.sources
|
|
22
24
|
reset_cache(sources) unless @cache_sources.equal?(sources)
|
|
23
|
-
|
|
25
|
+
date = at.getutc.to_date.iso8601
|
|
26
|
+
key = [provider_name, model_name, date].freeze
|
|
24
27
|
return @cache[key] if @cache.key?(key)
|
|
25
28
|
|
|
26
29
|
@cache.clear if @cache.size >= CACHE_LIMIT
|
|
27
|
-
@cache[key] = lookup_match(sources, provider_name, model_name)
|
|
30
|
+
@cache[key] = lookup_match(sources, provider_name, model_name)&.then do |match|
|
|
31
|
+
match.with(prices: prices_on(match.prices, date))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def prices_on(prices, date)
|
|
36
|
+
scheduled = prices.keys.grep(PriceKey::SCHEDULED_SUFFIX)
|
|
37
|
+
return prices if scheduled.empty?
|
|
38
|
+
|
|
39
|
+
current = prices.except(*scheduled)
|
|
40
|
+
scheduled.sort_by { |key| key[PriceKey::SCHEDULED_SUFFIX, 1] }.each do |key|
|
|
41
|
+
from = key[PriceKey::SCHEDULED_SUFFIX, 1]
|
|
42
|
+
current[key.delete_suffix("_from_#{from}")] = prices[key] if from <= date
|
|
43
|
+
end
|
|
44
|
+
current.freeze
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
def modifier_priced?(provider:, model:, modifier:)
|
|
@@ -45,35 +62,31 @@ module LlmCostTracker
|
|
|
45
62
|
provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
|
|
46
63
|
normalized = normalize_model_name(model_name)
|
|
47
64
|
|
|
48
|
-
sources.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end
|
|
52
|
-
nil
|
|
65
|
+
live = sources.reject { |source| source.prices.empty? }
|
|
66
|
+
live.lazy.filter_map { |source| exact_match(source, provider_model, model_name, normalized) }.first ||
|
|
67
|
+
live.lazy.filter_map { |source| fallback_match(source, normalized) }.first
|
|
53
68
|
end
|
|
54
69
|
|
|
55
|
-
def
|
|
70
|
+
def exact_match(source, provider_model, model_name, normalized)
|
|
56
71
|
table = source.prices
|
|
57
|
-
return nil if table.empty?
|
|
58
|
-
|
|
59
72
|
[[provider_model, :provider_model], [model_name, :model], [normalized, :normalized_model]].each do |key, by|
|
|
60
73
|
return build_match(source, key, by) if table.key?(key)
|
|
61
74
|
end
|
|
62
75
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return build_match(source, key, :unique_providerless_model)
|
|
76
|
+
dated = native_keys(table).find do |native|
|
|
77
|
+
snapshot_variant?(provider_model, native) || snapshot_variant?(normalized, native)
|
|
66
78
|
end
|
|
79
|
+
build_match(source, dated, :dated_snapshot) if dated
|
|
80
|
+
end
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
def fallback_match(source, normalized)
|
|
83
|
+
scan = native_keys(source.prices)
|
|
84
|
+
if (key = unique_in(scan) { |native| normalize_model_name(native) == normalized })
|
|
85
|
+
return build_match(source, key, :unique_providerless_model)
|
|
70
86
|
end
|
|
71
|
-
return build_match(source, dated, :dated_snapshot) if dated
|
|
72
87
|
|
|
73
88
|
unique_dated = unique_in(scan) { |native| snapshot_variant?(normalized, normalize_model_name(native)) }
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
nil
|
|
89
|
+
build_match(source, unique_dated, :unique_providerless_dated_snapshot) if unique_dated
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
def unique_in(keys, &)
|
|
@@ -82,7 +95,8 @@ module LlmCostTracker
|
|
|
82
95
|
end
|
|
83
96
|
|
|
84
97
|
def normalize_model_name(model)
|
|
85
|
-
model.to_s.split("/").last
|
|
98
|
+
name = model.to_s.split("/").last
|
|
99
|
+
name.match(BEDROCK_ANTHROPIC_ID)&.[](1) || name
|
|
86
100
|
end
|
|
87
101
|
|
|
88
102
|
def native_keys(table)
|
|
@@ -9,11 +9,8 @@ module LlmCostTracker
|
|
|
9
9
|
MAX_PERMUTED_MODIFIERS = 6
|
|
10
10
|
|
|
11
11
|
def self.normalize(value)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
mode = normalize_string(value.to_s)
|
|
15
|
-
return nil unless mode
|
|
16
|
-
return nil if STANDARD_MODE_VALUES.include?(mode)
|
|
12
|
+
mode = value.to_s.strip.downcase.tr("-", "_")
|
|
13
|
+
return nil if mode.empty? || STANDARD_MODE_VALUES.include?(mode)
|
|
17
14
|
|
|
18
15
|
warn_unknown_tokens(mode)
|
|
19
16
|
mode
|
|
@@ -65,14 +62,6 @@ module LlmCostTracker
|
|
|
65
62
|
modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
|
|
66
63
|
end
|
|
67
64
|
|
|
68
|
-
def self.normalize_string(value)
|
|
69
|
-
normalized = value.strip
|
|
70
|
-
return nil if normalized.empty?
|
|
71
|
-
|
|
72
|
-
normalized.downcase.tr("-", "_")
|
|
73
|
-
end
|
|
74
|
-
private_class_method :normalize_string
|
|
75
|
-
|
|
76
65
|
def self.warn_unknown_tokens(mode)
|
|
77
66
|
unknown = tokenize(mode) - KNOWN_MODIFIERS - STANDARD_MODE_VALUES
|
|
78
67
|
return if unknown.empty?
|
|
@@ -7,6 +7,7 @@ module LlmCostTracker
|
|
|
7
7
|
module Pricing
|
|
8
8
|
module PriceKey
|
|
9
9
|
ABOVE_CONTEXT_PREFIX = "above_context_"
|
|
10
|
+
SCHEDULED_SUFFIX = /_from_(\d{4}-\d{2}-\d{2})\z/
|
|
10
11
|
|
|
11
12
|
class << self
|
|
12
13
|
def build(dimension_key, mode: nil, above_context: false)
|
|
@@ -16,12 +17,13 @@ module LlmCostTracker
|
|
|
16
17
|
|
|
17
18
|
def price_key_for(key)
|
|
18
19
|
key = key.to_s
|
|
19
|
-
|
|
20
|
+
base = key.sub(SCHEDULED_SUFFIX, "")
|
|
21
|
+
dimension_key = strip_mode_prefix(base.delete_prefix(ABOVE_CONTEXT_PREFIX))
|
|
20
22
|
dimension = Usage::Catalog[dimension_key]
|
|
21
23
|
return nil unless dimension
|
|
22
|
-
return
|
|
24
|
+
return nil if dimension.token_key.nil? && base.start_with?(ABOVE_CONTEXT_PREFIX)
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
key
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def parse_dimension_key(key)
|