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
|
@@ -16,7 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
module Registry
|
|
17
17
|
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
18
18
|
CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
|
|
19
|
-
PRICE_KEYS = Usage::Catalog.
|
|
19
|
+
PRICE_KEYS = Usage::Catalog.all.select(&:token?).map(&:key).freeze
|
|
20
20
|
METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
|
|
21
21
|
|
|
22
22
|
class << self
|
|
@@ -185,10 +185,9 @@ module LlmCostTracker
|
|
|
185
185
|
def normalize_price_entry(model, price, context)
|
|
186
186
|
unknown = []
|
|
187
187
|
normalized = price.each_with_object({}) do |(key, value), acc|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
elsif registry_key
|
|
188
|
+
if key.to_s == CONTEXT_THRESHOLD_KEY
|
|
189
|
+
acc[CONTEXT_THRESHOLD_KEY] = Integer(value)
|
|
190
|
+
elsif (registry_key = PriceKey.price_key_for(key))
|
|
192
191
|
acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
|
|
193
192
|
elsif !METADATA_KEYS.include?(key)
|
|
194
193
|
unknown << key
|
|
@@ -213,12 +212,6 @@ module LlmCostTracker
|
|
|
213
212
|
)
|
|
214
213
|
end
|
|
215
214
|
|
|
216
|
-
def registry_key_for(key)
|
|
217
|
-
return CONTEXT_THRESHOLD_KEY if key.to_s == CONTEXT_THRESHOLD_KEY
|
|
218
|
-
|
|
219
|
-
PriceKey.price_key_for(key)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
215
|
def validate_price_entry(price, model:, context:)
|
|
223
216
|
return {} if price.nil?
|
|
224
217
|
return price if price.is_a?(Hash)
|
|
@@ -240,7 +233,7 @@ module LlmCostTracker
|
|
|
240
233
|
|
|
241
234
|
rate = {
|
|
242
235
|
amount: amount,
|
|
243
|
-
quantity:
|
|
236
|
+
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
244
237
|
currency: currency,
|
|
245
238
|
source_key: key
|
|
246
239
|
}
|
|
@@ -258,10 +251,6 @@ module LlmCostTracker
|
|
|
258
251
|
[dimension, tier]
|
|
259
252
|
end
|
|
260
253
|
|
|
261
|
-
def rate_quantity(dimension)
|
|
262
|
-
Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d
|
|
263
|
-
end
|
|
264
|
-
|
|
265
254
|
def upcased_currency(value)
|
|
266
255
|
(value || LlmCostTracker::DEFAULT_CURRENCY).upcase
|
|
267
256
|
end
|
|
@@ -44,19 +44,12 @@ module LlmCostTracker
|
|
|
44
44
|
return rate if rate
|
|
45
45
|
|
|
46
46
|
tier_rates.each do |candidate, candidate_rate|
|
|
47
|
-
return candidate_rate if
|
|
47
|
+
return candidate_rate if "_#{pricing_mode}_".include?("_#{candidate}_")
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
dimension_rates[:default]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def tier_includes?(tier_name, candidate_name)
|
|
54
|
-
tier_name == candidate_name ||
|
|
55
|
-
tier_name.start_with?("#{candidate_name}_") ||
|
|
56
|
-
tier_name.end_with?("_#{candidate_name}") ||
|
|
57
|
-
tier_name.include?("_#{candidate_name}_")
|
|
58
|
-
end
|
|
59
|
-
|
|
60
53
|
def charge_dimension_key(dimension)
|
|
61
54
|
billing_dimension = Usage::Catalog[dimension]
|
|
62
55
|
return billing_dimension.key if billing_dimension && billing_dimension.token_key.nil?
|
|
@@ -6,22 +6,26 @@ module LlmCostTracker
|
|
|
6
6
|
module RegistryDiff
|
|
7
7
|
class << self
|
|
8
8
|
def call(current_models, updated_models)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
fields = price_field_changes(current_models[model], updated_models[model])
|
|
14
|
-
changes[model] = fields if fields.any?
|
|
15
|
-
end
|
|
9
|
+
nested(
|
|
10
|
+
Registry.normalize_price_entries(current_models, context: "current price table"),
|
|
11
|
+
Registry.normalize_price_entries(updated_models, context: "updated price table")
|
|
12
|
+
)
|
|
16
13
|
rescue ArgumentError, TypeError => e
|
|
17
14
|
raise Error, e.message
|
|
18
15
|
end
|
|
19
16
|
|
|
17
|
+
def nested(current, updated)
|
|
18
|
+
(current.keys | updated.keys).sort.each_with_object({}) do |key, changes|
|
|
19
|
+
fields = price_field_changes(current[key], updated[key])
|
|
20
|
+
changes[key] = fields if fields.any?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
private
|
|
21
25
|
|
|
22
26
|
def price_field_changes(current_entry, updated_entry)
|
|
23
|
-
current_price = current_entry || {}
|
|
24
|
-
updated_price = updated_entry || {}
|
|
27
|
+
current_price = (current_entry || {}).transform_keys(&:to_s)
|
|
28
|
+
updated_price = (updated_entry || {}).transform_keys(&:to_s)
|
|
25
29
|
|
|
26
30
|
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
27
31
|
from = current_price[field]
|
|
@@ -43,59 +43,26 @@ module LlmCostTracker
|
|
|
43
43
|
force: false,
|
|
44
44
|
fetcher: Fetcher.new,
|
|
45
45
|
today: Date.today)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if response.not_modified
|
|
50
|
-
return refresh_result(
|
|
51
|
-
path: path,
|
|
52
|
-
url: url,
|
|
53
|
-
response: response,
|
|
54
|
-
changes: registry_changes(current, current),
|
|
55
|
-
suspicious: [],
|
|
56
|
-
written: false,
|
|
57
|
-
not_modified: true
|
|
58
|
-
)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
62
|
-
changes = registry_changes(current, remote)
|
|
63
|
-
suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
|
|
64
|
-
unless preview
|
|
46
|
+
response, remote, changes, suspicious = compare(path, url, fetcher, today)
|
|
47
|
+
written = !preview && !response.not_modified
|
|
48
|
+
if written
|
|
65
49
|
refuse_suspicious_snapshot!(path, suspicious) unless force || suspicious.empty?
|
|
66
50
|
RegistryWriter.new.call(path: path, registry: remote)
|
|
67
51
|
Pricing::Registry.reset!
|
|
68
52
|
end
|
|
69
|
-
|
|
53
|
+
RefreshResult.new(
|
|
70
54
|
path: path,
|
|
71
|
-
|
|
72
|
-
|
|
55
|
+
source_url: Redaction.text(url),
|
|
56
|
+
source_version: response.source_version,
|
|
73
57
|
changes: changes,
|
|
74
58
|
suspicious: suspicious,
|
|
75
|
-
written:
|
|
76
|
-
not_modified:
|
|
59
|
+
written: written,
|
|
60
|
+
not_modified: response.not_modified
|
|
77
61
|
)
|
|
78
62
|
end
|
|
79
63
|
|
|
80
64
|
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
81
|
-
|
|
82
|
-
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
83
|
-
|
|
84
|
-
if response.not_modified
|
|
85
|
-
return CheckResult.new(
|
|
86
|
-
path: path,
|
|
87
|
-
source_url: Redaction.text(url),
|
|
88
|
-
source_version: response.source_version,
|
|
89
|
-
changes: {},
|
|
90
|
-
suspicious: [],
|
|
91
|
-
up_to_date: true
|
|
92
|
-
)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
96
|
-
changes = registry_changes(current, remote)
|
|
97
|
-
suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
|
|
98
|
-
|
|
65
|
+
response, _remote, changes, suspicious = compare(path, url, fetcher, today)
|
|
99
66
|
CheckResult.new(
|
|
100
67
|
path: path,
|
|
101
68
|
source_url: Redaction.text(url),
|
|
@@ -147,6 +114,16 @@ module LlmCostTracker
|
|
|
147
114
|
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
148
115
|
end
|
|
149
116
|
|
|
117
|
+
def compare(path, url, fetcher, today)
|
|
118
|
+
current = load_registry(path)
|
|
119
|
+
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
120
|
+
return [response, nil, {}, []] if response.not_modified
|
|
121
|
+
|
|
122
|
+
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
123
|
+
changes = registry_changes(current, remote)
|
|
124
|
+
[response, remote, changes, SnapshotGuard.call(current: current, remote: remote, changes: changes)]
|
|
125
|
+
end
|
|
126
|
+
|
|
150
127
|
def load_registry(path)
|
|
151
128
|
return {} unless File.exist?(path)
|
|
152
129
|
|
|
@@ -164,29 +141,18 @@ module LlmCostTracker
|
|
|
164
141
|
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
165
142
|
end
|
|
166
143
|
|
|
167
|
-
def refresh_result(path:, url:, response:, changes:, suspicious:, written:, not_modified:)
|
|
168
|
-
RefreshResult.new(
|
|
169
|
-
path: path,
|
|
170
|
-
source_url: Redaction.text(url),
|
|
171
|
-
source_version: response.source_version,
|
|
172
|
-
changes: changes,
|
|
173
|
-
suspicious: suspicious,
|
|
174
|
-
written: written,
|
|
175
|
-
not_modified: not_modified
|
|
176
|
-
)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
144
|
def refuse_suspicious_snapshot!(path, suspicious)
|
|
180
145
|
listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
|
|
181
146
|
raise Error,
|
|
182
147
|
"Refusing to write pricing file #{path}: the remote snapshot has #{suspicious.size} " \
|
|
183
148
|
"suspicious price change(s):#{listed}\n" \
|
|
184
|
-
"Review them with
|
|
149
|
+
"Review them with llm_cost_tracker:prices:check, " \
|
|
150
|
+
"then refresh with FORCE=1 (force: true) to accept them."
|
|
185
151
|
end
|
|
186
152
|
|
|
187
153
|
def registry_changes(current, remote)
|
|
188
154
|
model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
189
|
-
charge_changes =
|
|
155
|
+
charge_changes = RegistryDiff.nested(
|
|
190
156
|
current.fetch("service_charges", {}),
|
|
191
157
|
remote.fetch("service_charges", {})
|
|
192
158
|
)
|
|
@@ -194,21 +160,6 @@ module LlmCostTracker
|
|
|
194
160
|
|
|
195
161
|
model_changes.merge("service_charges" => charge_changes)
|
|
196
162
|
end
|
|
197
|
-
|
|
198
|
-
def service_charges_diff(current, remote)
|
|
199
|
-
(current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
|
|
200
|
-
current_rates = (current[provider] || {}).transform_keys(&:to_s)
|
|
201
|
-
remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
|
|
202
|
-
(current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
|
|
203
|
-
from = current_rates[component]
|
|
204
|
-
to = remote_rates[component]
|
|
205
|
-
next if from == to
|
|
206
|
-
|
|
207
|
-
changes[provider] ||= {}
|
|
208
|
-
changes[provider][component] = { "from" => from, "to" => to }
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
163
|
end
|
|
213
164
|
end
|
|
214
165
|
end
|
|
@@ -10,7 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def match?(url)
|
|
13
|
-
|
|
13
|
+
uri_matches?(url) do |uri|
|
|
14
|
+
HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.include?("/v1/messages")
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def provider_names
|
|
@@ -32,7 +34,9 @@ module LlmCostTracker
|
|
|
32
34
|
model: response["model"] || request[:model],
|
|
33
35
|
provider_response_id: response["id"],
|
|
34
36
|
usage_source: Usage::Source::RESPONSE,
|
|
35
|
-
request: request
|
|
37
|
+
request: request,
|
|
38
|
+
content: Array(response["content"]).grep(Hash).map(&:deep_symbolize_keys),
|
|
39
|
+
**stop_fields(response)
|
|
36
40
|
)
|
|
37
41
|
end
|
|
38
42
|
|
|
@@ -51,7 +55,9 @@ module LlmCostTracker
|
|
|
51
55
|
provider_response_id: response_id,
|
|
52
56
|
usage_source: Usage::Source::STREAM_FINAL,
|
|
53
57
|
request: request,
|
|
54
|
-
stream: true
|
|
58
|
+
stream: true,
|
|
59
|
+
content: content_blocks(events),
|
|
60
|
+
**stop_fields(final_delta(events))
|
|
55
61
|
)
|
|
56
62
|
else
|
|
57
63
|
build_unknown_stream_usage(
|
|
@@ -67,12 +73,36 @@ module LlmCostTracker
|
|
|
67
73
|
"anthropic"
|
|
68
74
|
end
|
|
69
75
|
|
|
76
|
+
def retain_stream_event?(data)
|
|
77
|
+
data.is_a?(Hash) && data.dig("content_block", "type") == "fallback"
|
|
78
|
+
end
|
|
79
|
+
|
|
70
80
|
private
|
|
71
81
|
|
|
72
82
|
def symbolize_request(request_body)
|
|
73
83
|
safe_json_parse(request_body).deep_symbolize_keys
|
|
74
84
|
end
|
|
75
85
|
|
|
86
|
+
def final_delta(events)
|
|
87
|
+
find_event_value(events, reverse: true) { |data| data["delta"] if data["type"] == "message_delta" }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def content_blocks(events)
|
|
91
|
+
blocks = []
|
|
92
|
+
each_event_data(events) do |data|
|
|
93
|
+
block = data["content_block"] if data["type"] == "content_block_start"
|
|
94
|
+
blocks << block.deep_symbolize_keys if block.is_a?(Hash)
|
|
95
|
+
end
|
|
96
|
+
blocks
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def stop_fields(source)
|
|
100
|
+
return {} unless source.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
details = source["stop_details"]
|
|
103
|
+
{ stop_reason: source["stop_reason"], refusal_category: (details["category"] if details.is_a?(Hash)) }
|
|
104
|
+
end
|
|
105
|
+
|
|
76
106
|
def stream_usage(events)
|
|
77
107
|
latest_delta = find_event_value(events, reverse: true) do |data|
|
|
78
108
|
data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
|
|
@@ -11,17 +11,23 @@ module LlmCostTracker
|
|
|
11
11
|
provider_response_id:,
|
|
12
12
|
usage_source:,
|
|
13
13
|
request: nil,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
stream: false,
|
|
15
|
+
stop_reason: nil,
|
|
16
|
+
refusal_category: nil,
|
|
17
|
+
content: nil)
|
|
18
|
+
pricing_mode = UsageExtractor.pricing_mode(request: request, usage: usage)
|
|
19
|
+
line_items = UsageExtractor.service_line_items(usage) +
|
|
20
|
+
UsageExtractor.iteration_line_items(usage, content: content) +
|
|
21
|
+
UsageExtractor.refusal_line_items(usage, stop_reason:, refusal_category:)
|
|
16
22
|
Event.build(
|
|
17
23
|
provider: "anthropic",
|
|
18
24
|
provider_response_id: provider_response_id,
|
|
19
|
-
pricing_mode: pricing_mode
|
|
20
|
-
model: model,
|
|
25
|
+
pricing_mode: pricing_mode,
|
|
26
|
+
model: UsageExtractor.served_model(usage, content) || model,
|
|
21
27
|
token_usage: UsageExtractor.token_usage(usage),
|
|
22
28
|
stream: stream,
|
|
23
29
|
usage_source: usage_source,
|
|
24
|
-
service_line_items:
|
|
30
|
+
service_line_items: line_items
|
|
25
31
|
)
|
|
26
32
|
end
|
|
27
33
|
end
|
|
@@ -9,34 +9,100 @@ module LlmCostTracker
|
|
|
9
9
|
"web_fetch_request" => :web_fetch_requests
|
|
10
10
|
}.freeze
|
|
11
11
|
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
12
|
-
|
|
12
|
+
BILLED_REFUSAL_CATEGORIES = %w[bio frontier_llm reasoning_extraction].freeze
|
|
13
|
+
BEDROCK_REGIONAL_PROFILE = /\A(?!global\.)[a-z]+\.anthropic\./
|
|
14
|
+
private_constant :SERVER_TOOL_LINE_ITEMS,
|
|
15
|
+
:DATA_RESIDENCY_GEOS,
|
|
16
|
+
:BILLED_REFUSAL_CATEGORIES,
|
|
17
|
+
:BEDROCK_REGIONAL_PROFILE
|
|
13
18
|
|
|
14
19
|
def self.token_usage(usage)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
cache_read = usage[:cache_read_input_tokens].to_i
|
|
18
|
-
cache_write, cache_write_extended = cache_writes(usage)
|
|
20
|
+
entries = [usage, *iterations(usage, "compaction")]
|
|
21
|
+
writes = entries.map { |entry| cache_writes(entry) }
|
|
19
22
|
|
|
20
23
|
Usage::TokenUsage.build(
|
|
21
|
-
input_tokens:
|
|
22
|
-
output_tokens:
|
|
23
|
-
cache_read_input_tokens:
|
|
24
|
-
cache_write_input_tokens:
|
|
25
|
-
cache_write_extended_input_tokens:
|
|
24
|
+
input_tokens: entries.sum { |entry| entry[:input_tokens].to_i },
|
|
25
|
+
output_tokens: entries.sum { |entry| entry[:output_tokens].to_i },
|
|
26
|
+
cache_read_input_tokens: entries.sum { |entry| entry[:cache_read_input_tokens].to_i },
|
|
27
|
+
cache_write_input_tokens: writes.sum(&:first),
|
|
28
|
+
cache_write_extended_input_tokens: writes.sum(&:last),
|
|
26
29
|
hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
|
|
27
30
|
)
|
|
28
31
|
end
|
|
29
32
|
|
|
33
|
+
def self.served_model(usage, content = nil)
|
|
34
|
+
served = iterations(usage, "fallback_message").last&.dig(:model)
|
|
35
|
+
served ||= fallback_blocks(content).last&.dig(:to, :model)
|
|
36
|
+
served&.to_s.presence
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.iteration_line_items(usage, content: nil)
|
|
40
|
+
other_model_iterations(usage, content).map do |iteration|
|
|
41
|
+
tokens = token_usage(iteration)
|
|
42
|
+
Charges::LineItem.build(
|
|
43
|
+
dimension_key: "model_iteration",
|
|
44
|
+
quantity: 1,
|
|
45
|
+
pricing_basis: "provider_usage",
|
|
46
|
+
provider_field: "usage.iterations.#{iteration[:type]}",
|
|
47
|
+
details: tokens.to_h.select { |_key, count| count.positive? }.merge(model: iteration[:model].to_s)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.refusal_line_items(usage, stop_reason:, refusal_category:)
|
|
53
|
+
return [] unless stop_reason.to_s == "refusal" && token_usage(usage).output_tokens.zero?
|
|
54
|
+
return [] if BILLED_REFUSAL_CATEGORIES.include?(refusal_category.to_s)
|
|
55
|
+
|
|
56
|
+
[Charges::LineItem.build(
|
|
57
|
+
dimension_key: "billed_request",
|
|
58
|
+
quantity: 1,
|
|
59
|
+
rate_amount: 0,
|
|
60
|
+
cost: 0,
|
|
61
|
+
pricing_basis: "provider_usage",
|
|
62
|
+
price_source: "provider_response",
|
|
63
|
+
provider_field: "stop_details.category",
|
|
64
|
+
details: { category: refusal_category&.to_s }.compact
|
|
65
|
+
)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.other_model_iterations(usage, content)
|
|
69
|
+
advisors = iterations(usage, "advisor_message")
|
|
70
|
+
served = served_model(usage, content)
|
|
71
|
+
return advisors unless served
|
|
72
|
+
|
|
73
|
+
categories = fallback_blocks(content).map { |block| block.dig(:trigger, :category).to_s }
|
|
74
|
+
declined = iterations(usage, "message").reject { |entry| ["", served].include?(entry[:model].to_s) }
|
|
75
|
+
hops = declined.group_by { |entry| entry[:model].to_s }.values
|
|
76
|
+
billed = hops.select.with_index do |hop, index|
|
|
77
|
+
hop.any? { |entry| entry[:output_tokens].to_i.positive? } ||
|
|
78
|
+
BILLED_REFUSAL_CATEGORIES.include?(categories[index])
|
|
79
|
+
end
|
|
80
|
+
advisors + billed.flatten
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.fallback_blocks(content)
|
|
84
|
+
Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.iterations(usage, type)
|
|
88
|
+
Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
|
|
89
|
+
end
|
|
90
|
+
|
|
30
91
|
def self.pricing_mode(request:, usage:)
|
|
31
92
|
speed = usage&.dig(:speed) || request&.dig(:speed)
|
|
32
93
|
service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
|
|
33
94
|
geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
|
|
34
95
|
|
|
35
96
|
modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
|
|
36
|
-
modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
|
|
97
|
+
modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo) || bedrock_regional?(request&.dig(:model))
|
|
37
98
|
Pricing::Mode.compose(modes)
|
|
38
99
|
end
|
|
39
100
|
|
|
101
|
+
def self.bedrock_regional?(model)
|
|
102
|
+
model.to_s.match?(BEDROCK_REGIONAL_PROFILE) &&
|
|
103
|
+
Pricing::Matcher.modifier_priced?(provider: "bedrock", model: model.to_s, modifier: "data_residency")
|
|
104
|
+
end
|
|
105
|
+
|
|
40
106
|
def self.service_line_items(usage)
|
|
41
107
|
server_tool_use = usage[:server_tool_use]
|
|
42
108
|
return [] unless server_tool_use.is_a?(Hash)
|
|
@@ -58,7 +124,10 @@ module LlmCostTracker
|
|
|
58
124
|
def self.cache_writes(usage)
|
|
59
125
|
cache_creation = usage[:cache_creation]
|
|
60
126
|
if cache_creation.is_a?(Hash)
|
|
61
|
-
|
|
127
|
+
five = cache_creation[:ephemeral_5m_input_tokens].to_i
|
|
128
|
+
one = cache_creation[:ephemeral_1h_input_tokens].to_i
|
|
129
|
+
# A stream's message_delta total also counts later server-tool breakpoints, which are always 5m.
|
|
130
|
+
[five + [usage[:cache_creation_input_tokens].to_i - five - one, 0].max, one]
|
|
62
131
|
else
|
|
63
132
|
warn_unexpected_cache_creation(cache_creation, usage)
|
|
64
133
|
[usage[:cache_creation_input_tokens].to_i, 0]
|
|
@@ -6,13 +6,9 @@ module LlmCostTracker
|
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include Openai::ResponseParser
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
images/generations images/edits images/variations
|
|
13
|
-
].freeze
|
|
14
|
-
|
|
15
|
-
PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
|
|
9
|
+
PATH_PATTERN = %r{
|
|
10
|
+
\A/openai/(?:deployments/[^/]+|v1)/(?:#{Openai::Parser::TRACKED_ENDPOINTS.join('|')}|responses/resp_[^/]+)\z
|
|
11
|
+
}x
|
|
16
12
|
|
|
17
13
|
class << self
|
|
18
14
|
def match?(url)
|