llm_cost_tracker 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
|
@@ -5,14 +5,17 @@ require "bigdecimal/util"
|
|
|
5
5
|
require_relative "../usage/catalog"
|
|
6
6
|
require_relative "../charges/line_item"
|
|
7
7
|
require_relative "rate"
|
|
8
|
+
require_relative "calculation/iterations"
|
|
9
|
+
require_relative "calculation/quantities"
|
|
10
|
+
require_relative "calculation/snapshot"
|
|
11
|
+
require_relative "calculation/totals"
|
|
8
12
|
|
|
9
13
|
module LlmCostTracker
|
|
10
14
|
module Pricing
|
|
11
15
|
class Calculation
|
|
12
16
|
RATE_DENOMINATOR_TOKENS = Pricing::RATE_BASIS_QUANTITIES.fetch("per_million_tokens")
|
|
13
|
-
SNAPSHOT_SCHEMA_VERSION = 1
|
|
14
17
|
CACHE_INPUT_KEYS = %w[cache_read_input cache_write_input].freeze
|
|
15
|
-
private_constant :RATE_DENOMINATOR_TOKENS, :
|
|
18
|
+
private_constant :RATE_DENOMINATOR_TOKENS, :CACHE_INPUT_KEYS
|
|
16
19
|
|
|
17
20
|
def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil, at: Time.now)
|
|
18
21
|
new(provider: provider,
|
|
@@ -29,12 +32,18 @@ module LlmCostTracker
|
|
|
29
32
|
@model = model
|
|
30
33
|
@token_usage = token_usage
|
|
31
34
|
@line_items = line_items
|
|
32
|
-
@
|
|
35
|
+
@requested_mode = mode
|
|
33
36
|
@usage_source = usage_source
|
|
34
37
|
@at = at
|
|
35
38
|
end
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
def mode
|
|
41
|
+
return @mode if defined?(@mode)
|
|
42
|
+
|
|
43
|
+
tokens = Mode.tokenize(@requested_mode) - ["off_peak"]
|
|
44
|
+
tokens << "off_peak" if off_peak?
|
|
45
|
+
@mode = Mode.compose(tokens)
|
|
46
|
+
end
|
|
38
47
|
|
|
39
48
|
def match
|
|
40
49
|
return @match if defined?(@match)
|
|
@@ -49,7 +58,7 @@ module LlmCostTracker
|
|
|
49
58
|
usage: @token_usage,
|
|
50
59
|
quantities: quantities,
|
|
51
60
|
prices: match.prices,
|
|
52
|
-
pricing_mode:
|
|
61
|
+
pricing_mode: mode,
|
|
53
62
|
cache_at_input_rate: cache_keys_at_input_rate
|
|
54
63
|
)
|
|
55
64
|
end
|
|
@@ -57,8 +66,8 @@ module LlmCostTracker
|
|
|
57
66
|
def token_cost
|
|
58
67
|
return @token_cost if defined?(@token_cost)
|
|
59
68
|
|
|
60
|
-
known = priceable? && @usage_source != Usage::Source::UNKNOWN && !only_unpriced_lines?
|
|
61
|
-
@token_cost = known ?
|
|
69
|
+
known = priceable? && @usage_source != Usage::Source::UNKNOWN && !only_unpriced_lines? && !unsplit_total?
|
|
70
|
+
@token_cost = known ? Totals.token_cost(priced_token_line_items, match.source.currency) : nil
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
def priced_line_items
|
|
@@ -72,34 +81,39 @@ module LlmCostTracker
|
|
|
72
81
|
|
|
73
82
|
@snapshot =
|
|
74
83
|
if priceable?
|
|
75
|
-
|
|
76
|
-
elsif
|
|
77
|
-
|
|
84
|
+
Snapshot.for_match(match, counted_service_lines, priced_token_line_items)
|
|
85
|
+
elsif counted_service_lines.any?
|
|
86
|
+
Snapshot.for_service_charges(counted_service_lines, cost.currency)
|
|
78
87
|
end
|
|
79
88
|
end
|
|
80
89
|
|
|
81
90
|
def cost
|
|
82
91
|
return @cost if defined?(@cost)
|
|
83
92
|
|
|
84
|
-
@cost =
|
|
93
|
+
@cost = Totals.with_service_lines(token_cost, counted_service_lines)
|
|
85
94
|
end
|
|
86
95
|
|
|
87
96
|
def cost_status
|
|
88
97
|
@cost_status ||= begin
|
|
89
|
-
status = billed_status ||
|
|
90
|
-
|
|
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
|
+
status = billed_status || priced_status
|
|
99
|
+
@iterations&.partial? && status != Charges::CostStatus::UNKNOWN ? Charges::CostStatus::PARTIAL : status
|
|
98
100
|
end
|
|
99
101
|
end
|
|
100
102
|
|
|
103
|
+
def unsplit_total?
|
|
104
|
+
return false unless match && @token_usage.total_tokens.to_i.positive?
|
|
105
|
+
|
|
106
|
+
@token_usage.priced_quantities.values.none?(&:positive?) &&
|
|
107
|
+
match.prices.values_at("input", "output").any? { |rate| rate.to_f.positive? }
|
|
108
|
+
end
|
|
109
|
+
|
|
101
110
|
private
|
|
102
111
|
|
|
112
|
+
def off_peak?
|
|
113
|
+
windows = match&.prices&.[](Registry::OFF_PEAK_WINDOWS_KEY)
|
|
114
|
+
windows && OffPeak.cover?(windows, @at)
|
|
115
|
+
end
|
|
116
|
+
|
|
103
117
|
def cache_keys_at_input_rate
|
|
104
118
|
return [] if match.source.name == "pricing_overrides" || !match.key.start_with?("openai/")
|
|
105
119
|
|
|
@@ -113,14 +127,7 @@ module LlmCostTracker
|
|
|
113
127
|
end
|
|
114
128
|
|
|
115
129
|
def quantities
|
|
116
|
-
@quantities ||=
|
|
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
|
|
130
|
+
@quantities ||= Quantities.new(@token_usage, @line_items, match&.prices || {}).to_h
|
|
124
131
|
end
|
|
125
132
|
|
|
126
133
|
def unpriced_line_items
|
|
@@ -135,188 +142,90 @@ module LlmCostTracker
|
|
|
135
142
|
@line_items.find { |line_item| line_item.kind == "billed_request" }
|
|
136
143
|
end
|
|
137
144
|
|
|
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
145
|
def all_billable_unpriced?
|
|
153
146
|
billable = quantities.select { |_key, quantity| quantity.positive? }.keys
|
|
154
147
|
billable.any? && billable.none? { |key| effective[key] }
|
|
155
148
|
end
|
|
156
149
|
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def build_token_cost
|
|
162
|
-
by_component = priced_token_line_items.group_by { |item| item.dimension.parent || item.dimension.key }
|
|
163
|
-
components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
|
|
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) }
|
|
168
|
-
end
|
|
169
|
-
Charges::Cost.new(
|
|
170
|
-
components: components.freeze,
|
|
171
|
-
total: priced_token_line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) },
|
|
172
|
-
currency: match.source.currency
|
|
173
|
-
)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def build_snapshot
|
|
177
|
-
{
|
|
178
|
-
"schema_version" => SNAPSHOT_SCHEMA_VERSION,
|
|
179
|
-
"source" => match.source.name,
|
|
180
|
-
"source_key" => match.key,
|
|
181
|
-
"source_version" => match.source.version,
|
|
182
|
-
"matched_by" => match.matched_by.to_s,
|
|
183
|
-
"currency" => match.source.currency,
|
|
184
|
-
"rates" => charge_rates(kept_service_lines).merge(charge_rates(priced_token_line_items))
|
|
185
|
-
}
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def build_service_snapshot
|
|
189
|
-
primary = kept_service_lines.first
|
|
190
|
-
{
|
|
191
|
-
"schema_version" => SNAPSHOT_SCHEMA_VERSION,
|
|
192
|
-
"source" => primary.price_source,
|
|
193
|
-
"source_version" => primary.price_source_version,
|
|
194
|
-
"matched_by" => "service_charges",
|
|
195
|
-
"currency" => cost.currency,
|
|
196
|
-
"rates" => charge_rates(kept_service_lines)
|
|
197
|
-
}
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def charge_rates(line_items)
|
|
201
|
-
line_items.each_with_object({}) do |line_item, rates|
|
|
202
|
-
next if line_item.price_key.nil? || line_item.rate_amount.nil?
|
|
203
|
-
|
|
204
|
-
rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def rate_entry(amount, quantity)
|
|
209
|
-
{ "amount" => amount.to_d.to_s("F"), "quantity" => Integer(quantity) }
|
|
150
|
+
def only_unpriced_lines?
|
|
151
|
+
billable = priced_line_items.select(&:billable?)
|
|
152
|
+
billable.any? && billable.none?(&:priced?)
|
|
210
153
|
end
|
|
211
154
|
|
|
212
155
|
def price_token(line_item)
|
|
213
156
|
price = priceable? && effective[line_item.dimension.key]
|
|
214
|
-
|
|
215
|
-
end
|
|
157
|
+
return line_item unless price
|
|
216
158
|
|
|
217
|
-
|
|
218
|
-
Pricing::Rate.new(
|
|
219
|
-
amount: price.amount.to_d,
|
|
220
|
-
quantity: RATE_DENOMINATOR_TOKENS.to_d,
|
|
221
|
-
currency: match.source.currency,
|
|
222
|
-
source: match.source.name,
|
|
223
|
-
source_key: price.key,
|
|
224
|
-
source_version: match.source.version
|
|
225
|
-
)
|
|
159
|
+
line_item.with_rate(match.rate(price.amount.to_d, RATE_DENOMINATOR_TOKENS.to_d, price.key))
|
|
226
160
|
end
|
|
227
161
|
|
|
228
162
|
def price_service(line_item)
|
|
229
|
-
return
|
|
163
|
+
return iterations.price(line_item) if line_item.kind == "model_iteration"
|
|
230
164
|
return line_item if line_item.priced? || !line_item.billable? || billed_line
|
|
231
165
|
|
|
232
166
|
rate = model_rate(line_item) ||
|
|
233
|
-
ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode:
|
|
167
|
+
ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: mode)
|
|
234
168
|
return line_item unless rate
|
|
235
169
|
|
|
236
|
-
line_item.with_rate(rate)
|
|
170
|
+
billed_minimum(line_item).with_rate(rate)
|
|
237
171
|
end
|
|
238
172
|
|
|
239
|
-
def
|
|
240
|
-
|
|
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
|
|
173
|
+
def model_rate(line_item)
|
|
174
|
+
return unless priceable?
|
|
251
175
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
176
|
+
key = model_price_key(line_item.kind)
|
|
177
|
+
return unless key
|
|
178
|
+
|
|
179
|
+
quantity = Pricing::RATE_BASIS_QUANTITIES.fetch(Usage::Catalog[line_item.kind].rate_basis)
|
|
180
|
+
match.rate(match.prices[key].to_d, quantity.to_d, "#{match.key}.#{key}")
|
|
255
181
|
end
|
|
256
182
|
|
|
257
|
-
def
|
|
258
|
-
|
|
183
|
+
def model_price_key(kind)
|
|
184
|
+
modes = mode ? Mode.permutations_for(mode) : []
|
|
185
|
+
[*modes.map { |permutation| PriceKey.build(kind, mode: permutation) }, kind]
|
|
186
|
+
.find { |key| match.prices[key].is_a?(Numeric) }
|
|
187
|
+
end
|
|
259
188
|
|
|
260
|
-
|
|
189
|
+
def billed_minimum(line_item)
|
|
190
|
+
seconds = match&.prices&.[](Registry::MINIMUM_BILLED_SECONDS_KEY) if line_item.kind == "transcription_minute"
|
|
191
|
+
seconds ? line_item.with(quantity: [line_item.quantity, BigDecimal(seconds) / 60].max) : line_item
|
|
261
192
|
end
|
|
262
193
|
|
|
263
|
-
def
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
dimension = Usage::Catalog[line_item.kind]
|
|
272
|
-
Pricing::Rate.new(
|
|
273
|
-
amount: match.prices[key].to_d,
|
|
274
|
-
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
275
|
-
currency: match.source.currency,
|
|
276
|
-
source: match.source.name,
|
|
277
|
-
source_key: "#{match.key}.#{key}",
|
|
278
|
-
source_version: match.source.version
|
|
279
|
-
)
|
|
194
|
+
def iterations
|
|
195
|
+
@iterations ||= Iterations.new(provider: @provider, requested_mode: @requested_mode, at: @at)
|
|
280
196
|
end
|
|
281
197
|
|
|
282
|
-
def
|
|
283
|
-
|
|
198
|
+
def priced_token_line_items
|
|
199
|
+
@priced_token_line_items ||= priced_line_items.select(&:token?)
|
|
200
|
+
end
|
|
284
201
|
|
|
285
|
-
|
|
286
|
-
|
|
202
|
+
def priced_service_line_items
|
|
203
|
+
@priced_service_line_items ||= priced_line_items.reject(&:token?)
|
|
204
|
+
end
|
|
287
205
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
291
|
-
@kept_service_lines = matching
|
|
206
|
+
def counted_service_lines
|
|
207
|
+
@counted_service_lines ||= Totals.in_call_currency(priced_service_line_items.select(&:priced?), token_cost)
|
|
292
208
|
end
|
|
293
209
|
|
|
294
|
-
def
|
|
295
|
-
|
|
296
|
-
|
|
210
|
+
def billed_status
|
|
211
|
+
return unless billed_line
|
|
212
|
+
|
|
213
|
+
unpriced_attempt = priced_line_items.any? { |item| item.kind == "model_iteration" && item.unpriced? }
|
|
214
|
+
return Charges::CostStatus::PARTIAL if unpriced_attempt
|
|
297
215
|
|
|
298
|
-
|
|
299
|
-
Charges::Cost.new(
|
|
300
|
-
components: cost ? cost.components : {}.freeze,
|
|
301
|
-
total: (cost&.total || BigDecimal("0")) + service_total,
|
|
302
|
-
currency: (cost&.currency || kept_service_lines.first.currency).to_s
|
|
303
|
-
)
|
|
216
|
+
billed_line.priced? && cost.total.positive? ? Charges::CostStatus::COMPLETE : billed_line.cost_status
|
|
304
217
|
end
|
|
305
218
|
|
|
306
|
-
def
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
219
|
+
def priced_status
|
|
220
|
+
Charges::CostStatus.call(
|
|
221
|
+
token_usage: @token_usage,
|
|
222
|
+
usage_source: @usage_source,
|
|
223
|
+
token_cost: token_cost,
|
|
224
|
+
token_pricing_partial: !token_cost.nil? && priced_token_line_items.any?(&:unpriced?),
|
|
225
|
+
service_line_items: priced_service_line_items,
|
|
226
|
+
total_cost: cost&.total
|
|
312
227
|
)
|
|
313
228
|
end
|
|
314
|
-
|
|
315
|
-
def token_pricing_partial?
|
|
316
|
-
return false unless token_cost
|
|
317
|
-
|
|
318
|
-
priced_token_line_items.any?(&:unpriced?)
|
|
319
|
-
end
|
|
320
229
|
end
|
|
321
230
|
end
|
|
322
231
|
end
|
|
@@ -8,11 +8,23 @@ require_relative "registry"
|
|
|
8
8
|
module LlmCostTracker
|
|
9
9
|
module Pricing
|
|
10
10
|
module Matcher
|
|
11
|
-
Match = Data.define(:source, :key, :prices, :matched_by)
|
|
11
|
+
Match = Data.define(:source, :key, :prices, :matched_by) do
|
|
12
|
+
def rate(amount, quantity, source_key)
|
|
13
|
+
Rate.new(
|
|
14
|
+
amount: amount,
|
|
15
|
+
quantity: quantity,
|
|
16
|
+
currency: source.currency,
|
|
17
|
+
source: source.name,
|
|
18
|
+
source_key: source_key,
|
|
19
|
+
source_version: source.version
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
12
23
|
|
|
13
24
|
CACHE_LIMIT = 2048
|
|
14
25
|
BEDROCK_ANTHROPIC_ID = /\A(?:[a-z]+\.)?anthropic\.(claude-.+?)(?:-v\d+(?::\d+)?)?\z/
|
|
15
|
-
|
|
26
|
+
VERTEX_VERSION = /@(?=\d{8}\z)/
|
|
27
|
+
private_constant :CACHE_LIMIT, :BEDROCK_ANTHROPIC_ID, :VERTEX_VERSION
|
|
16
28
|
|
|
17
29
|
class << self
|
|
18
30
|
def lookup(provider:, model:, at: Time.now)
|
|
@@ -21,14 +33,11 @@ module LlmCostTracker
|
|
|
21
33
|
return nil if model_name.empty?
|
|
22
34
|
|
|
23
35
|
sources = Registry.sources
|
|
24
|
-
reset_cache(sources) unless @cache_sources.equal?(sources)
|
|
25
36
|
date = at.getutc.to_date.iso8601
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@cache[key] = lookup_match(sources, provider_name, model_name)&.then do |match|
|
|
31
|
-
match.with(prices: prices_on(match.prices, date))
|
|
37
|
+
cached(sources, [provider_name, model_name, date].freeze) do
|
|
38
|
+
lookup_match(sources, provider_name, model_name)&.then do |match|
|
|
39
|
+
match.with(prices: prices_on(match.prices, date))
|
|
40
|
+
end
|
|
32
41
|
end
|
|
33
42
|
end
|
|
34
43
|
|
|
@@ -44,15 +53,23 @@ module LlmCostTracker
|
|
|
44
53
|
current.freeze
|
|
45
54
|
end
|
|
46
55
|
|
|
47
|
-
def modifier_priced?(provider:, model:, modifier:)
|
|
48
|
-
|
|
49
|
-
return false unless
|
|
56
|
+
def modifier_priced?(provider:, model:, modifier:, owners: nil)
|
|
57
|
+
match = lookup(provider: provider, model: model)
|
|
58
|
+
return false unless match && (owners.nil? || match.key.start_with?(*owners))
|
|
50
59
|
|
|
51
|
-
prices.any? { |key, _| key.to_s.
|
|
60
|
+
match.prices.any? { |key, _| key.to_s.match?(/(?:\A|_)#{modifier}_/) }
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
private
|
|
55
64
|
|
|
65
|
+
def cached(sources, key)
|
|
66
|
+
reset_cache(sources) unless @cache_sources.equal?(sources)
|
|
67
|
+
return @cache[key] if @cache.key?(key)
|
|
68
|
+
|
|
69
|
+
@cache.clear if @cache.size >= CACHE_LIMIT
|
|
70
|
+
@cache[key] = yield
|
|
71
|
+
end
|
|
72
|
+
|
|
56
73
|
def reset_cache(sources)
|
|
57
74
|
@cache_sources = sources
|
|
58
75
|
@cache = {}
|
|
@@ -95,7 +112,7 @@ module LlmCostTracker
|
|
|
95
112
|
end
|
|
96
113
|
|
|
97
114
|
def normalize_model_name(model)
|
|
98
|
-
name = model.to_s.split("/").last
|
|
115
|
+
name = model.to_s.split("/").last.sub(VERTEX_VERSION, "-")
|
|
99
116
|
name.match(BEDROCK_ANTHROPIC_ID)&.[](1) || name
|
|
100
117
|
end
|
|
101
118
|
|
|
@@ -4,9 +4,13 @@ module LlmCostTracker
|
|
|
4
4
|
module Pricing
|
|
5
5
|
module Mode
|
|
6
6
|
STANDARD_MODE_VALUES = %w[auto default standard standard_only unspecified].freeze
|
|
7
|
-
KNOWN_MODIFIERS = %w[
|
|
7
|
+
KNOWN_MODIFIERS = %w[
|
|
8
|
+
batch flex priority scale fast ultrafast on_demand data_residency off_peak reserved provisioned_throughput
|
|
9
|
+
].freeze
|
|
8
10
|
HOST_DERIVED_MODIFIERS = %w[data_residency].freeze
|
|
9
11
|
MAX_PERMUTED_MODIFIERS = 6
|
|
12
|
+
TOKEN = /#{Regexp.union(KNOWN_MODIFIERS)}(?=_|\z)|[^_]+/
|
|
13
|
+
private_constant :TOKEN
|
|
10
14
|
|
|
11
15
|
def self.normalize(value)
|
|
12
16
|
mode = value.to_s.strip.downcase.tr("-", "_")
|
|
@@ -33,24 +37,7 @@ module LlmCostTracker
|
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
def self.tokenize(value)
|
|
36
|
-
|
|
37
|
-
tokens = []
|
|
38
|
-
loop do
|
|
39
|
-
break if remaining.empty?
|
|
40
|
-
|
|
41
|
-
known = KNOWN_MODIFIERS.find do |modifier|
|
|
42
|
-
remaining == modifier || remaining.start_with?("#{modifier}_")
|
|
43
|
-
end
|
|
44
|
-
if known
|
|
45
|
-
tokens << known
|
|
46
|
-
remaining = remaining.delete_prefix(known).delete_prefix("_")
|
|
47
|
-
else
|
|
48
|
-
first, _, rest = remaining.partition("_")
|
|
49
|
-
tokens << first unless first.empty?
|
|
50
|
-
remaining = rest
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
tokens
|
|
40
|
+
value.to_s.downcase.tr("-", "_").scan(TOKEN)
|
|
54
41
|
end
|
|
55
42
|
|
|
56
43
|
def self.permutations_for(value)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
module OffPeak
|
|
6
|
+
HOURS = /\A((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d|24:00)\z/
|
|
7
|
+
KEYS = %w[hours_utc weekdays].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def windows(value, label:)
|
|
11
|
+
windows = Array(value).map { |window| window.is_a?(Hash) ? window.transform_keys(&:to_s) : {} }
|
|
12
|
+
unless value.is_a?(Array) && list?(windows) { |window| window?(window) }
|
|
13
|
+
raise ArgumentError,
|
|
14
|
+
"#{label} must be a list of windows like " \
|
|
15
|
+
'{"weekdays" => [1, 2, 3, 4, 5], "hours_utc" => ["00:00-01:00", "10:00-24:00"]} ' \
|
|
16
|
+
"(ISO weekdays, UTC hours, start inclusive, end exclusive), got #{value.inspect}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
windows.map(&:freeze).freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cover?(windows, at)
|
|
23
|
+
time = at.getutc
|
|
24
|
+
weekday = time.wday.zero? ? 7 : time.wday
|
|
25
|
+
minute = (time.hour * 60) + time.min
|
|
26
|
+
windows.any? do |window|
|
|
27
|
+
window["weekdays"].include?(weekday) &&
|
|
28
|
+
window["hours_utc"].any? { |hours| minutes(hours).then { |from, to| minute >= from && minute < to } }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def window?(window)
|
|
35
|
+
weekdays, hours = window.values_at("weekdays", "hours_utc")
|
|
36
|
+
window.keys.sort == KEYS && list?(weekdays) { |day| day.is_a?(Integer) && day.between?(1, 7) } &&
|
|
37
|
+
list?(hours) { |range| minutes(range)&.then { |from, to| from < to } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def list?(value, &)
|
|
41
|
+
value.is_a?(Array) && value.any? && value.all?(&)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def minutes(range)
|
|
45
|
+
range.to_s.match(HOURS)&.captures&.map { |clock| (clock[0, 2].to_i * 60) + clock[3, 2].to_i }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal/util"
|
|
4
|
+
|
|
5
|
+
require_relative "../../logging"
|
|
6
|
+
require_relative "../off_peak"
|
|
7
|
+
require_relative "../price_key"
|
|
8
|
+
require_relative "../rate"
|
|
9
|
+
|
|
10
|
+
module LlmCostTracker
|
|
11
|
+
module Pricing
|
|
12
|
+
module Registry
|
|
13
|
+
class Normalizer
|
|
14
|
+
def initialize(context)
|
|
15
|
+
@context = context
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def prices(table)
|
|
19
|
+
table = {} if table.nil?
|
|
20
|
+
raise ArgumentError, "#{@context} must be a hash of models" unless table.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
table.each_with_object({}) { |(model, entry), prices| prices[model.to_s] = price_entry(model, entry) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rates(registry)
|
|
26
|
+
charges = registry.fetch("service_charges", {})
|
|
27
|
+
raise ArgumentError, "#{@context} service_charges must be a hash" unless charges.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
currency = (registry.dig("metadata", "currency") || LlmCostTracker::DEFAULT_CURRENCY).upcase
|
|
30
|
+
charges.to_h do |provider, entries|
|
|
31
|
+
[provider, provider_rates(entries, currency, "#{@context} service_charges.#{provider}")]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def price_entry(model, entry)
|
|
38
|
+
fields = hash_entry(model, entry).map { |key, value| [key, price_field(model, key, value)] }
|
|
39
|
+
unknown = fields.reject { |key, field| field || METADATA_KEYS.include?(key) }.map(&:first)
|
|
40
|
+
warn_unknown_keys(model, unknown) unless unknown.empty?
|
|
41
|
+
fields.filter_map(&:last).to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def hash_entry(model, entry)
|
|
45
|
+
return {} if entry.nil?
|
|
46
|
+
return entry if entry.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, "price entry for #{model.inspect} in #{@context} must be a hash"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def price_field(model, key, value)
|
|
52
|
+
name = key.to_s
|
|
53
|
+
case name
|
|
54
|
+
when CONTEXT_THRESHOLD_KEY, MINIMUM_BILLED_SECONDS_KEY then [name, Integer(value)]
|
|
55
|
+
when OFF_PEAK_WINDOWS_KEY then [name, OffPeak.windows(value, label: "#{name} for #{model.inspect}")]
|
|
56
|
+
else
|
|
57
|
+
price_key = PriceKey.price_key_for(name)
|
|
58
|
+
[price_key, amount(value, "price for #{price_key.inspect}")] if price_key
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def warn_unknown_keys(model, keys)
|
|
63
|
+
Logging.warn(
|
|
64
|
+
"Unknown price keys #{keys.inspect} for #{model.inspect} in #{@context}; " \
|
|
65
|
+
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def provider_rates(entries, currency, context)
|
|
70
|
+
raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
entries.each_with_object({}) do |(key, value), rates|
|
|
73
|
+
dimension, tier = dimension_and_tier(key.to_s, context)
|
|
74
|
+
rate = service_rate(key.to_s, value, dimension, currency, context)
|
|
75
|
+
dimension_rates = rates[dimension.key] ||= { tiers: {} }
|
|
76
|
+
if tier
|
|
77
|
+
dimension_rates[:tiers][tier] = rate
|
|
78
|
+
else
|
|
79
|
+
dimension_rates[:default] = rate
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dimension_and_tier(key, context)
|
|
85
|
+
dimension, tier = PriceKey.parse_dimension_key(key)
|
|
86
|
+
return [dimension, tier] if dimension && dimension.token_key.nil?
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing dimension"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def service_rate(key, value, dimension, currency, context)
|
|
92
|
+
{
|
|
93
|
+
amount: amount(value, "service charge price amount for #{key.inspect} in #{context}"),
|
|
94
|
+
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
95
|
+
currency: currency,
|
|
96
|
+
source_key: key
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def amount(value, label)
|
|
101
|
+
decimal = BigDecimal(value.to_s)
|
|
102
|
+
raise ArgumentError, "#{label} must be finite (got #{value})" unless decimal.finite?
|
|
103
|
+
raise ArgumentError, "#{label} must be non-negative (got #{value})" if decimal.negative?
|
|
104
|
+
|
|
105
|
+
decimal
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|