llm_cost_tracker 0.9.0 → 0.10.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 +29 -1
- data/README.md +2 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +28 -6
- data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +31 -28
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +6 -17
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
- data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +72 -27
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -1
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +18 -7
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Openai
|
|
6
|
+
module Hosts
|
|
7
|
+
API_HOSTS = %w[
|
|
8
|
+
api.openai.com
|
|
9
|
+
us.api.openai.com
|
|
10
|
+
eu.api.openai.com
|
|
11
|
+
au.api.openai.com
|
|
12
|
+
ca.api.openai.com
|
|
13
|
+
jp.api.openai.com
|
|
14
|
+
in.api.openai.com
|
|
15
|
+
sg.api.openai.com
|
|
16
|
+
kr.api.openai.com
|
|
17
|
+
gb.api.openai.com
|
|
18
|
+
ae.api.openai.com
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def api?(host)
|
|
26
|
+
API_HOSTS.include?(host.to_s.downcase)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def data_residency?(host)
|
|
30
|
+
host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Openai
|
|
6
|
+
module ModelFamilies
|
|
7
|
+
DATA_RESIDENCY_MODEL_PATTERN =
|
|
8
|
+
/\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
|
|
9
|
+
|
|
10
|
+
IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
|
|
11
|
+
|
|
12
|
+
CHARACTER_BILLED_TTS_MODEL_PATTERN = /\Atts-1(-hd)?\z/
|
|
13
|
+
|
|
14
|
+
REASONING_MODEL_PATTERNS = [
|
|
15
|
+
/\Agpt-5(\b|[\d.-])/i,
|
|
16
|
+
/\Ao\d+(\b|[\d.-])/i
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
NON_REASONING_GPT5_PATTERN = /\Agpt-5(?:\.\d+)?-chat\b/i
|
|
20
|
+
|
|
21
|
+
CHAT_COMPLETIONS_SEARCH_MODEL_PATTERN = /-search-(?:preview|api)\b/i
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def data_residency?(model)
|
|
26
|
+
model.to_s.match?(DATA_RESIDENCY_MODEL_PATTERN)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def image_output?(model)
|
|
30
|
+
model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def character_billed_tts?(model)
|
|
34
|
+
model.to_s.match?(CHARACTER_BILLED_TTS_MODEL_PATTERN)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def chat_completions_search?(model)
|
|
38
|
+
model.to_s.match?(CHAT_COMPLETIONS_SEARCH_MODEL_PATTERN)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reasoning?(model)
|
|
42
|
+
name = model.to_s
|
|
43
|
+
return false if name.empty?
|
|
44
|
+
return false if NON_REASONING_GPT5_PATTERN.match?(name)
|
|
45
|
+
|
|
46
|
+
REASONING_MODEL_PATTERNS.any? { |pattern| pattern.match?(name) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -12,11 +12,13 @@ module LlmCostTracker
|
|
|
12
12
|
require_relative "generators/llm_cost_tracker/install_generator"
|
|
13
13
|
require_relative "generators/llm_cost_tracker/prices_generator"
|
|
14
14
|
require_relative "generators/llm_cost_tracker/call_rollups_generator"
|
|
15
|
-
require_relative "generators/llm_cost_tracker/
|
|
15
|
+
require_relative "generators/llm_cost_tracker/async_ingestion_generator"
|
|
16
16
|
require_relative "generators/llm_cost_tracker/reconciliation_generator"
|
|
17
17
|
require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
|
|
18
18
|
require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
|
|
19
19
|
require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
|
|
20
|
+
require_relative "generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator"
|
|
21
|
+
require_relative "generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator"
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
rake_tasks do
|
|
@@ -28,7 +28,7 @@ module LlmCostTracker
|
|
|
28
28
|
@provider = provider.to_s
|
|
29
29
|
@period_start = parse_date(period_start)
|
|
30
30
|
@period_end = parse_date(period_end)
|
|
31
|
-
@scope =
|
|
31
|
+
@scope = (scope || {}).to_h.transform_keys { |key| key.to_s.to_sym }.slice(*SCOPE_KEYS)
|
|
32
32
|
@currency = (currency || Ledger::Rollups::DEFAULT_CURRENCY).to_s.upcase
|
|
33
33
|
@drilldown_limit = drilldown_limit
|
|
34
34
|
raise ArgumentError, "source must be present" if @source.empty?
|
|
@@ -37,7 +37,7 @@ module LlmCostTracker
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def call
|
|
40
|
-
provider_total =
|
|
40
|
+
provider_total = scoped_cost_invoices_in_window
|
|
41
41
|
.sum(:billed_amount)
|
|
42
42
|
.then { |sum| BigDecimal(sum.to_s) }
|
|
43
43
|
local_index = local_attribution_index_distinct
|
|
@@ -66,7 +66,7 @@ module LlmCostTracker
|
|
|
66
66
|
unmatched_local_calls: cap_by_amount(unmatched_locals, :total_cost),
|
|
67
67
|
unmatched_local_calls_total: unmatched_local_calls_total_count(invoice_basis_values),
|
|
68
68
|
non_cost_rows: cap_by_amount(non_cost_rows, :billed_amount),
|
|
69
|
-
non_cost_rows_total:
|
|
69
|
+
non_cost_rows_total: scoped_non_cost_invoices_relation.count
|
|
70
70
|
)
|
|
71
71
|
end
|
|
72
72
|
|
|
@@ -88,10 +88,10 @@ module LlmCostTracker
|
|
|
88
88
|
relation.to_a
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
def
|
|
91
|
+
def scoped_cost_invoices_in_window
|
|
92
92
|
relation = scoped_invoices_relation
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
.where(period_start: period_start..)
|
|
94
|
+
.where(period_end: ..period_end)
|
|
95
95
|
|
|
96
96
|
connection = ProviderInvoice.connection
|
|
97
97
|
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
@@ -187,10 +187,8 @@ module LlmCostTracker
|
|
|
187
187
|
|
|
188
188
|
def unmatched_provider_rows_from_sql(local_index)
|
|
189
189
|
rows = BASIS_DIMENSION.each_key.flat_map do |basis|
|
|
190
|
-
next [] if basis == PERIOD_ONLY_BASIS
|
|
191
|
-
|
|
192
190
|
column = BASIS_DIMENSION[basis].to_s
|
|
193
|
-
relation =
|
|
191
|
+
relation = scoped_cost_invoices_in_window
|
|
194
192
|
relation = where_match_basis_eq(relation, basis)
|
|
195
193
|
relation = where_metadata_present(relation, column)
|
|
196
194
|
values = local_index[basis].to_a
|
|
@@ -213,10 +211,8 @@ module LlmCostTracker
|
|
|
213
211
|
|
|
214
212
|
def unmatched_provider_rows_total_count(local_index)
|
|
215
213
|
BASIS_DIMENSION.each_key.sum do |basis|
|
|
216
|
-
next 0 if basis == PERIOD_ONLY_BASIS
|
|
217
|
-
|
|
218
214
|
column = BASIS_DIMENSION[basis].to_s
|
|
219
|
-
relation =
|
|
215
|
+
relation = scoped_cost_invoices_in_window
|
|
220
216
|
relation = where_match_basis_eq(relation, basis)
|
|
221
217
|
relation = where_metadata_present(relation, column)
|
|
222
218
|
values = local_index[basis].to_a
|
|
@@ -227,13 +223,9 @@ module LlmCostTracker
|
|
|
227
223
|
|
|
228
224
|
def local_attribution_index_distinct
|
|
229
225
|
BASIS_DIMENSION.each_key.to_h do |basis|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
column = BASIS_DIMENSION[basis]
|
|
234
|
-
values = scoped_calls_relation.where.not(column => nil).distinct.pluck(column)
|
|
235
|
-
[basis, Set.new(values)]
|
|
236
|
-
end
|
|
226
|
+
column = BASIS_DIMENSION[basis]
|
|
227
|
+
values = scoped_calls_relation.where.not(column => nil).distinct.pluck(column)
|
|
228
|
+
[basis, Set.new(values)]
|
|
237
229
|
end
|
|
238
230
|
end
|
|
239
231
|
|
|
@@ -252,38 +244,31 @@ module LlmCostTracker
|
|
|
252
244
|
|
|
253
245
|
def unmatched_local_calls_total_count(invoice_basis_values)
|
|
254
246
|
unmatched = 0
|
|
255
|
-
scoped_calls_relation.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
247
|
+
scoped_calls_relation.in_batches(of: 1_000) do |batch|
|
|
248
|
+
batch.pluck(*ATTRIBUTION_KEYS).each do |row|
|
|
249
|
+
attribution = ATTRIBUTION_KEYS.zip(row).each_with_object({}) do |(key, value), acc|
|
|
250
|
+
acc[key] = value unless value.nil? || value.to_s.empty?
|
|
251
|
+
end
|
|
252
|
+
next if attribution.empty?
|
|
253
|
+
next if local_call_matched?(attribution, invoice_basis_values)
|
|
254
|
+
|
|
255
|
+
unmatched += 1
|
|
259
256
|
end
|
|
260
|
-
next if attribution.empty?
|
|
261
|
-
next if local_call_matched?(attribution, invoice_basis_values)
|
|
262
|
-
|
|
263
|
-
unmatched += 1
|
|
264
257
|
end
|
|
265
258
|
unmatched
|
|
266
259
|
end
|
|
267
260
|
|
|
268
261
|
def invoice_basis_values_distinct_sql
|
|
269
262
|
BASIS_DIMENSION.each_key.to_h do |basis|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
relation = where_metadata_present(relation, column)
|
|
277
|
-
values = pluck_metadata_distinct(relation, column)
|
|
278
|
-
[basis, Set.new(values)]
|
|
279
|
-
end
|
|
263
|
+
column = BASIS_DIMENSION[basis].to_s
|
|
264
|
+
relation = scoped_cost_invoices_in_window
|
|
265
|
+
relation = where_match_basis_eq(relation, basis)
|
|
266
|
+
relation = where_metadata_present(relation, column)
|
|
267
|
+
values = pluck_metadata_distinct(relation, column)
|
|
268
|
+
[basis, Set.new(values)]
|
|
280
269
|
end
|
|
281
270
|
end
|
|
282
271
|
|
|
283
|
-
def non_cost_invoices_total_count
|
|
284
|
-
scoped_non_cost_invoices_relation.count
|
|
285
|
-
end
|
|
286
|
-
|
|
287
272
|
def scoped_non_cost_invoices_relation
|
|
288
273
|
connection = ProviderInvoice.connection
|
|
289
274
|
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
@@ -414,10 +399,6 @@ module LlmCostTracker
|
|
|
414
399
|
((local - provider) * 100 / provider).round(4).to_f
|
|
415
400
|
end
|
|
416
401
|
|
|
417
|
-
def symbolize(hash)
|
|
418
|
-
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
419
|
-
end
|
|
420
|
-
|
|
421
402
|
def parse_date(value)
|
|
422
403
|
return value if value.is_a?(Date)
|
|
423
404
|
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
|
|
7
7
|
require_relative "fingerprint"
|
|
8
|
+
require_relative "../../providers/anthropic/tier_classification"
|
|
8
9
|
|
|
9
10
|
module LlmCostTracker
|
|
10
11
|
module Reconciliation
|
|
@@ -18,8 +19,6 @@ module LlmCostTracker
|
|
|
18
19
|
ROW_TYPE_COST = "cost"
|
|
19
20
|
AUTHORITY_COST_API = "cost_api"
|
|
20
21
|
DEFAULT_METER = "tokens"
|
|
21
|
-
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
22
|
-
private_constant :DATA_RESIDENCY_GEOS
|
|
23
22
|
|
|
24
23
|
module_function
|
|
25
24
|
|
|
@@ -109,7 +108,9 @@ module LlmCostTracker
|
|
|
109
108
|
def pricing_mode_for(result)
|
|
110
109
|
modes = []
|
|
111
110
|
modes << "batch" if result[:service_tier].to_s.downcase == "batch"
|
|
112
|
-
|
|
111
|
+
if LlmCostTracker::Providers::Anthropic::TierClassification.data_residency_geo?(result[:inference_geo])
|
|
112
|
+
modes << "data_residency"
|
|
113
|
+
end
|
|
113
114
|
modes.empty? ? nil : modes.uniq.join("_")
|
|
114
115
|
end
|
|
115
116
|
|
|
@@ -15,10 +15,6 @@ module LlmCostTracker
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
Formatter.new(report_data).to_s
|
|
18
|
-
rescue LoadError => e
|
|
19
|
-
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
20
|
-
rescue StandardError => e
|
|
21
|
-
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
18
|
end
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -32,6 +32,14 @@ module LlmCostTracker
|
|
|
32
32
|
.delete_all
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def prune_inbox(older_than:, now: Time.now.utc)
|
|
36
|
+
cutoff = resolve_cutoff(older_than, now)
|
|
37
|
+
require_relative "ingestion"
|
|
38
|
+
return 0 unless LlmCostTracker::Ingestion::InboxEntry.table_exists?
|
|
39
|
+
|
|
40
|
+
LlmCostTracker::Ingestion::InboxEntry.where(tracked_at: ...cutoff).delete_all
|
|
41
|
+
end
|
|
42
|
+
|
|
35
43
|
private
|
|
36
44
|
|
|
37
45
|
def resolve_cutoff(older_than, now)
|
|
@@ -61,20 +69,24 @@ module LlmCostTracker
|
|
|
61
69
|
|
|
62
70
|
def prune_batch(cutoff, batch_size)
|
|
63
71
|
LlmCostTracker::Call.transaction do
|
|
64
|
-
|
|
72
|
+
cache_rollups = LlmCostTracker.configuration.cache_rollups
|
|
73
|
+
rows = pluck_prunable(cutoff, batch_size, with_rollup_columns: cache_rollups)
|
|
65
74
|
next 0 if rows.empty?
|
|
66
75
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
end
|
|
76
|
+
ids = cache_rollups ? rows.map(&:first) : rows
|
|
77
|
+
deleted = LlmCostTracker::Call.where(id: ids).delete_all
|
|
78
|
+
LlmCostTracker::Ledger::Rollups.decrement!(rows) if cache_rollups && deleted.positive?
|
|
71
79
|
deleted
|
|
72
80
|
end
|
|
73
81
|
end
|
|
74
82
|
|
|
75
|
-
def pluck_prunable(cutoff, batch_size)
|
|
76
|
-
LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
77
|
-
|
|
83
|
+
def pluck_prunable(cutoff, batch_size, with_rollup_columns:)
|
|
84
|
+
relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
85
|
+
if with_rollup_columns
|
|
86
|
+
relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
|
|
87
|
+
else
|
|
88
|
+
relation.pluck(:id)
|
|
89
|
+
end
|
|
78
90
|
end
|
|
79
91
|
end
|
|
80
92
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/string/inflections"
|
|
4
|
-
require "json"
|
|
5
4
|
|
|
6
5
|
module LlmCostTracker
|
|
7
6
|
module Tags
|
|
@@ -24,7 +23,7 @@ module LlmCostTracker
|
|
|
24
23
|
class << self
|
|
25
24
|
def call(tags, config: LlmCostTracker.configuration)
|
|
26
25
|
tags = (tags || {}).to_h
|
|
27
|
-
redacted =
|
|
26
|
+
redacted = config.normalized_redacted_tag_keys
|
|
28
27
|
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
29
28
|
max_count = [config.max_tag_count.to_i, 0].max
|
|
30
29
|
tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
|
|
@@ -32,6 +31,18 @@ module LlmCostTracker
|
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
34
|
+
def cap(tags, config: LlmCostTracker.configuration)
|
|
35
|
+
tags = (tags || {}).to_h
|
|
36
|
+
max_count = [config.max_tag_count.to_i, 0].max
|
|
37
|
+
return tags if tags.size <= max_count
|
|
38
|
+
|
|
39
|
+
tags.to_a.last(max_count).to_h
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalized_key(key)
|
|
43
|
+
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
44
|
+
end
|
|
45
|
+
|
|
35
46
|
private
|
|
36
47
|
|
|
37
48
|
def sanitized_value(key, value, redacted, limit)
|
|
@@ -92,27 +103,12 @@ module LlmCostTracker
|
|
|
92
103
|
redacted.any? { |candidate| redacted_key_component?(normalized, candidate) }
|
|
93
104
|
end
|
|
94
105
|
|
|
95
|
-
def normalized_key(key)
|
|
96
|
-
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
97
|
-
end
|
|
98
|
-
|
|
99
106
|
def redacted_key_component?(key, candidate)
|
|
100
107
|
key == candidate ||
|
|
101
108
|
key.start_with?("#{candidate}_") ||
|
|
102
109
|
key.end_with?("_#{candidate}") ||
|
|
103
110
|
key.include?("_#{candidate}_")
|
|
104
111
|
end
|
|
105
|
-
|
|
106
|
-
def value_string(value)
|
|
107
|
-
case value
|
|
108
|
-
when Hash, Array
|
|
109
|
-
JSON.generate(value)
|
|
110
|
-
else
|
|
111
|
-
value.to_s
|
|
112
|
-
end
|
|
113
|
-
rescue JSON::GeneratorError, TypeError
|
|
114
|
-
value.to_s
|
|
115
|
-
end
|
|
116
112
|
end
|
|
117
113
|
end
|
|
118
114
|
end
|
|
@@ -21,6 +21,10 @@ module LlmCostTracker
|
|
|
21
21
|
:total_tokens,
|
|
22
22
|
:hidden_output_tokens
|
|
23
23
|
) do
|
|
24
|
+
def priced_quantities
|
|
25
|
+
Billing::Components::TOKEN_PRICED.to_h { |component| [component.key, public_send(component.token_key)] }
|
|
26
|
+
end
|
|
27
|
+
|
|
24
28
|
def self.build_from_tokens(tokens)
|
|
25
29
|
return tokens if tokens.is_a?(self)
|
|
26
30
|
raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
-
require "bigdecimal"
|
|
5
4
|
require "securerandom"
|
|
6
5
|
|
|
7
6
|
require_relative "ingestion"
|
|
@@ -15,28 +14,30 @@ module LlmCostTracker
|
|
|
15
14
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
16
15
|
|
|
17
16
|
class << self
|
|
18
|
-
def enforce_budget!
|
|
17
|
+
def enforce_budget!(provider: nil, model: nil, request: nil)
|
|
19
18
|
return unless LlmCostTracker.configuration.enabled
|
|
20
19
|
|
|
21
|
-
Budget.enforce!
|
|
20
|
+
Budget.enforce!(provider: provider, model: model, request: request)
|
|
22
21
|
end
|
|
23
22
|
|
|
24
|
-
def record(
|
|
23
|
+
def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
|
|
25
24
|
return unless LlmCostTracker.configuration.enabled
|
|
26
25
|
|
|
27
|
-
pricing_mode = Pricing.normalize_mode(pricing_mode) ||
|
|
26
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode) || event.pricing_mode
|
|
28
27
|
cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
|
|
29
|
-
provider:
|
|
30
|
-
model:
|
|
31
|
-
tokens:
|
|
32
|
-
line_items:
|
|
28
|
+
provider: event.provider,
|
|
29
|
+
model: event.model,
|
|
30
|
+
tokens: event.token_usage,
|
|
31
|
+
line_items: event.line_items,
|
|
33
32
|
pricing_mode: pricing_mode
|
|
34
33
|
)
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
if cost_data.nil? && event.token_usage.total_tokens.positive? && priced_line_items.none?(&:priced?)
|
|
36
|
+
Pricing::Unknown.process(event.model)
|
|
37
|
+
end
|
|
37
38
|
|
|
38
39
|
event = build_event(
|
|
39
|
-
|
|
40
|
+
event: event,
|
|
40
41
|
pricing_mode: pricing_mode,
|
|
41
42
|
cost_data: cost_data,
|
|
42
43
|
pricing_snapshot: pricing_snapshot,
|
|
@@ -46,21 +47,21 @@ module LlmCostTracker
|
|
|
46
47
|
context_tags: context_tags
|
|
47
48
|
)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
if Ingestion.async?
|
|
51
|
+
Ingestion::Inbox.save(event)
|
|
52
|
+
Ingestion::Worker.ensure_started
|
|
53
|
+
else
|
|
54
|
+
Ledger::Store.insert(event, skip_existence_check: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
yield if block_given?
|
|
51
58
|
notify_subscribers(event)
|
|
52
59
|
Budget.check!(event)
|
|
53
60
|
|
|
54
61
|
event
|
|
55
62
|
end
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
if LlmCostTracker.configuration.durable_ingestion
|
|
59
|
-
Ingestion::Inbox.save(event)
|
|
60
|
-
else
|
|
61
|
-
Ingestion::Inline.save(event)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
+
private
|
|
64
65
|
|
|
65
66
|
def notify_subscribers(event)
|
|
66
67
|
return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
|
|
@@ -70,45 +71,25 @@ module LlmCostTracker
|
|
|
70
71
|
Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def token_pricing_partial?(token_usage:, cost_data:)
|
|
76
|
-
return false unless cost_data
|
|
77
|
-
|
|
78
|
-
Billing::Components::TOKEN_PRICED.any? do |component|
|
|
79
|
-
token_usage.public_send(component.token_key).positive? && cost_data[component.cost_key].nil?
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
|
|
74
|
+
def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
|
|
84
75
|
metadata:, latency_ms:, context_tags:)
|
|
85
76
|
context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
|
|
86
|
-
cost =
|
|
77
|
+
cost = Pricing.combine_with_service_lines(cost_data, line_items)
|
|
87
78
|
cost_status = Billing::CostStatus.call(
|
|
88
|
-
token_usage:
|
|
89
|
-
usage_source:
|
|
79
|
+
token_usage: event.token_usage,
|
|
80
|
+
usage_source: event.usage_source,
|
|
90
81
|
token_cost: cost_data,
|
|
91
|
-
token_pricing_partial: token_pricing_partial?(
|
|
82
|
+
token_pricing_partial: Pricing.token_pricing_partial?(event.token_usage, cost_data),
|
|
92
83
|
service_line_items: line_items.reject(&:token?),
|
|
93
84
|
total_cost: cost&.fetch(:total_cost, nil)
|
|
94
85
|
)
|
|
95
86
|
|
|
96
|
-
|
|
87
|
+
event.with(
|
|
97
88
|
event_id: SecureRandom.uuid,
|
|
98
|
-
provider: capture.provider,
|
|
99
|
-
model: capture.model,
|
|
100
|
-
token_usage: capture.token_usage,
|
|
101
89
|
pricing_mode: pricing_mode,
|
|
102
90
|
cost: cost,
|
|
103
|
-
tags:
|
|
91
|
+
tags: build_tags(context_tags: context_tags, metadata: metadata),
|
|
104
92
|
latency_ms: finite_latency_ms(latency_ms),
|
|
105
|
-
stream: capture.stream,
|
|
106
|
-
usage_source: capture.usage_source,
|
|
107
|
-
provider_response_id: capture.provider_response_id,
|
|
108
|
-
provider_project_id: capture.provider_project_id,
|
|
109
|
-
provider_api_key_id: capture.provider_api_key_id,
|
|
110
|
-
provider_workspace_id: capture.provider_workspace_id,
|
|
111
|
-
batch: capture.batch,
|
|
112
93
|
tracked_at: Time.now.utc,
|
|
113
94
|
cost_status: cost_status,
|
|
114
95
|
pricing_snapshot: pricing_snapshot,
|
|
@@ -116,6 +97,11 @@ module LlmCostTracker
|
|
|
116
97
|
)
|
|
117
98
|
end
|
|
118
99
|
|
|
100
|
+
def build_tags(context_tags:, metadata:)
|
|
101
|
+
sanitized_metadata = LlmCostTracker::Tags::Sanitizer.call(metadata.to_h)
|
|
102
|
+
LlmCostTracker::Tags::Sanitizer.cap(context_tags.merge(sanitized_metadata)).freeze
|
|
103
|
+
end
|
|
104
|
+
|
|
119
105
|
def finite_latency_ms(latency_ms)
|
|
120
106
|
return nil if latency_ms.nil?
|
|
121
107
|
|
|
@@ -123,33 +109,6 @@ module LlmCostTracker
|
|
|
123
109
|
rescue ArgumentError, TypeError, FloatDomainError
|
|
124
110
|
nil
|
|
125
111
|
end
|
|
126
|
-
|
|
127
|
-
def cost_with_service_lines(cost_data, line_items)
|
|
128
|
-
priced_services = line_items.reject(&:token?).select(&:priced?)
|
|
129
|
-
return cost_data if priced_services.empty?
|
|
130
|
-
|
|
131
|
-
base_currency = (cost_data && cost_data[:currency]) || Billing::LineItem::USD
|
|
132
|
-
matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
|
|
133
|
-
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
134
|
-
|
|
135
|
-
cost = cost_data ? cost_data.dup : {}
|
|
136
|
-
cost[:currency] ||= base_currency.to_s
|
|
137
|
-
return cost if matching.empty?
|
|
138
|
-
|
|
139
|
-
service_total = matching.sum(BigDecimal("0"), &:cost_value)
|
|
140
|
-
base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
|
|
141
|
-
cost[:total_cost] = (base_total + service_total).round(8)
|
|
142
|
-
cost
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def warn_currency_mismatch(lines, base_currency)
|
|
146
|
-
currencies = lines.map { |line| line.currency.to_s }.uniq.sort
|
|
147
|
-
Logging.warn(
|
|
148
|
-
"Service line currency mismatch: header is #{base_currency}, dropping " \
|
|
149
|
-
"#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
|
|
150
|
-
"Per-line costs are still recorded; header total reflects #{base_currency} only."
|
|
151
|
-
)
|
|
152
|
-
end
|
|
153
112
|
end
|
|
154
113
|
end
|
|
155
114
|
end
|