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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/README.md +2 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
  6. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  7. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  8. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  9. data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
  10. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  11. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  12. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  13. data/lib/llm_cost_tracker/budget.rb +28 -6
  14. data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
  15. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  16. data/lib/llm_cost_tracker/configuration.rb +31 -28
  17. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  18. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  19. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  20. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  21. data/lib/llm_cost_tracker/doctor.rb +6 -17
  22. data/lib/llm_cost_tracker/engine.rb +1 -2
  23. data/lib/llm_cost_tracker/errors.rb +3 -2
  24. data/lib/llm_cost_tracker/event.rb +47 -0
  25. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  26. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  27. 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
  28. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  29. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  37. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  38. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
  39. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  40. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  41. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
  43. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  44. data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
  45. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
  46. data/lib/llm_cost_tracker/integrations.rb +14 -13
  47. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  48. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  49. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  50. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  51. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  52. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  53. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  54. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  55. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  56. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  57. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  58. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  59. data/lib/llm_cost_tracker/logging.rb +0 -4
  60. data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
  61. data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
  62. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  63. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  64. data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
  65. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  66. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
  67. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
  68. data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
  69. data/lib/llm_cost_tracker/parsers.rb +31 -4
  70. data/lib/llm_cost_tracker/prices.json +567 -579
  71. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  72. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  73. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  74. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  75. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  76. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  77. data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
  78. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  79. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  80. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  81. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  82. data/lib/llm_cost_tracker/pricing.rb +72 -27
  83. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  84. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  85. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  86. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  87. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  88. data/lib/llm_cost_tracker/railtie.rb +3 -1
  89. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  90. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  91. data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
  92. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
  93. data/lib/llm_cost_tracker/report.rb +0 -4
  94. data/lib/llm_cost_tracker/retention.rb +20 -8
  95. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  96. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  97. data/lib/llm_cost_tracker/tracker.rb +33 -74
  98. data/lib/llm_cost_tracker/version.rb +1 -1
  99. data/lib/llm_cost_tracker.rb +11 -15
  100. data/lib/tasks/llm_cost_tracker.rake +16 -2
  101. metadata +18 -7
  102. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  103. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  104. 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/durable_ingestion_generator"
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 = symbolize(scope || {}).slice(*SCOPE_KEYS)
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 = scoped_invoices_relation_for(:cost, fully_contained: true)
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: non_cost_invoices_total_count
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 scoped_invoices_relation_for(row_type_filter = nil, fully_contained: false)
91
+ def scoped_cost_invoices_in_window
92
92
  relation = scoped_invoices_relation
93
- relation = relation.where(period_start: period_start..).where(period_end: ..period_end) if fully_contained
94
- return relation unless row_type_filter == :cost
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 = scoped_invoices_relation_for(:cost, fully_contained: true)
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 = scoped_invoices_relation_for(:cost, fully_contained: true)
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
- if basis == PERIOD_ONLY_BASIS
231
- [basis, Set.new]
232
- else
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.find_each(batch_size: 1_000) do |call|
256
- attribution = ATTRIBUTION_KEYS.each_with_object({}) do |key, acc|
257
- value = call.public_send(key)
258
- acc[key] = value unless value.nil? || value.to_s.empty?
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
- if basis == PERIOD_ONLY_BASIS
271
- [basis, Set.new]
272
- else
273
- column = BASIS_DIMENSION[basis].to_s
274
- relation = scoped_invoices_relation_for(:cost, fully_contained: true)
275
- relation = where_match_basis_eq(relation, basis)
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
 
@@ -39,10 +39,6 @@ module LlmCostTracker
39
39
 
40
40
  delta_percent.abs <= threshold_percent
41
41
  end
42
-
43
- def empty?
44
- provider_total.zero? && local_total.zero?
45
- end
46
42
  end
47
43
  end
48
44
  end
@@ -81,6 +81,7 @@ module LlmCostTracker
81
81
 
82
82
  ProviderInvoiceImport.create!(
83
83
  source: source,
84
+ provider: provider,
84
85
  cursor: cursor,
85
86
  window_start: window&.first,
86
87
  window_end: window&.last,
@@ -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
- modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(result[:inference_geo].to_s.downcase)
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
- rows = pluck_prunable(cutoff, batch_size)
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
- deleted = LlmCostTracker::Call.where(id: rows.map(&:first)).delete_all
68
- if deleted.positive? && LlmCostTracker.configuration.cache_rollups
69
- LlmCostTracker::Ledger::Rollups.decrement!(rows)
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
- .pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
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 = Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
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(capture:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
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) || capture.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: capture.provider,
30
- model: capture.model,
31
- tokens: capture.token_usage,
32
- line_items: capture.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
- Pricing::Unknown.handle!(capture.model) if cost_data.nil? && capture.token_usage.total_tokens.positive?
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
- capture: capture,
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
- save_event(event)
50
- yield :after_save if block_given?
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
- def save_event(event)
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
- private
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 = cost_with_service_lines(cost_data, line_items)
77
+ cost = Pricing.combine_with_service_lines(cost_data, line_items)
87
78
  cost_status = Billing::CostStatus.call(
88
- token_usage: capture.token_usage,
89
- usage_source: capture.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?(token_usage: capture.token_usage, cost_data: cost_data),
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
- Event.new(
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: LlmCostTracker::Tags::Sanitizer.call(context_tags.merge(metadata.to_h)).freeze,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end