llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. 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
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -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
@@ -5,6 +5,7 @@ require "date"
5
5
  require "json"
6
6
 
7
7
  require_relative "import_result"
8
+ require_relative "sources/coercion"
8
9
  require_relative "../ledger/rollups"
9
10
 
10
11
  module LlmCostTracker
@@ -81,6 +82,7 @@ module LlmCostTracker
81
82
 
82
83
  ProviderInvoiceImport.create!(
83
84
  source: source,
85
+ provider: provider,
84
86
  cursor: cursor,
85
87
  window_start: window&.first,
86
88
  window_end: window&.last,
@@ -119,7 +121,7 @@ module LlmCostTracker
119
121
  def normalize_rows(rows)
120
122
  errors = []
121
123
  normalized = rows.each_with_index.filter_map do |row, index|
122
- attrs = symbolize(row)
124
+ attrs = Sources::Coercion.symbolize(row)
123
125
  missing = REQUIRED_FIELDS - attrs.keys
124
126
  if missing.any?
125
127
  errors << "row #{index}: missing #{missing.join(', ')}"
@@ -212,12 +214,6 @@ module LlmCostTracker
212
214
  raw.start_with?(prefix) ? raw : "#{prefix}#{raw}"
213
215
  end
214
216
 
215
- def symbolize(row)
216
- return row if row.is_a?(Hash) && row.keys.all?(Symbol)
217
-
218
- row.to_h.transform_keys { |key| key.to_s.to_sym }
219
- end
220
-
221
217
  def parse_date(value)
222
218
  return value if value.is_a?(Date)
223
219
 
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bigdecimal"
4
- require "json"
5
4
  require "time"
6
5
 
6
+ require_relative "coercion"
7
7
  require_relative "fingerprint"
8
+ require_relative "../../providers/anthropic/tier_classification"
8
9
 
9
10
  module LlmCostTracker
10
11
  module Reconciliation
@@ -18,13 +19,11 @@ 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
 
26
25
  def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
27
- payload = coerce_hash(response)
26
+ payload = Coercion.coerce_hash(response, label: "Anthropic Usage")
28
27
  buckets = Array(payload[:data])
29
28
  buckets.flat_map do |bucket|
30
29
  rows_for_bucket(bucket, authority: authority, row_type: row_type)
@@ -32,7 +31,7 @@ module LlmCostTracker
32
31
  end
33
32
 
34
33
  def rows_for_bucket(bucket, authority:, row_type:)
35
- bucket = symbolize(bucket)
34
+ bucket = Coercion.symbolize(bucket)
36
35
  starting_at = bucket[:starting_at]
37
36
  ending_at = bucket[:ending_at]
38
37
  return [] unless starting_at && ending_at
@@ -51,7 +50,7 @@ module LlmCostTracker
51
50
  end
52
51
 
53
52
  def row_for_result(raw, period_start:, period_end:, starting_at:, ending_at:, authority:, row_type:)
54
- result = symbolize(raw)
53
+ result = Coercion.symbolize(raw)
55
54
  raw_amount = result[:amount]
56
55
  return nil if raw_amount.nil?
57
56
 
@@ -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
 
@@ -121,19 +122,11 @@ module LlmCostTracker
121
122
  end
122
123
 
123
124
  def fingerprint_for(result, starting_at:, ending_at:)
124
- attributes = result.merge(starting_at: normalized_epoch(starting_at),
125
- ending_at: normalized_epoch(ending_at))
125
+ attributes = result.merge(starting_at: Coercion.normalized_epoch(starting_at),
126
+ ending_at: Coercion.normalized_epoch(ending_at))
126
127
  Fingerprint.compute(FINGERPRINT_KEYS, attributes)
127
128
  end
128
129
 
129
- def normalized_epoch(value)
130
- return value.to_i if value.is_a?(Numeric)
131
-
132
- Time.parse(value.to_s).utc.to_i
133
- rescue ArgumentError
134
- value.to_s
135
- end
136
-
137
130
  def parse_date(value)
138
131
  return value if value.is_a?(Date)
139
132
  return Time.at(value).utc.to_date if value.is_a?(Numeric)
@@ -149,22 +142,6 @@ module LlmCostTracker
149
142
  end
150
143
  (time - 1).utc.to_date
151
144
  end
152
-
153
- def coerce_hash(response)
154
- return {} if response.nil?
155
- return symbolize(response) if response.is_a?(Hash)
156
-
157
- parsed = JSON.parse(response.to_s)
158
- raise ArgumentError, "Anthropic Usage payload must be a JSON object" unless parsed.is_a?(Hash)
159
-
160
- symbolize(parsed)
161
- rescue JSON::ParserError => e
162
- raise ArgumentError, "Unable to parse Anthropic Usage payload: #{e.message}"
163
- end
164
-
165
- def symbolize(hash)
166
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
167
- end
168
145
  end
169
146
  end
170
147
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module LlmCostTracker
7
+ module Reconciliation
8
+ module Sources
9
+ module Coercion
10
+ module_function
11
+
12
+ def symbolize(hash)
13
+ return hash if hash.is_a?(Hash) && hash.keys.all?(Symbol)
14
+
15
+ hash.to_h.transform_keys { |key| key.to_s.to_sym }
16
+ end
17
+
18
+ def normalized_epoch(value)
19
+ return value.to_i if value.is_a?(Numeric)
20
+
21
+ Time.parse(value.to_s).utc.to_i
22
+ rescue ArgumentError
23
+ value.to_s
24
+ end
25
+
26
+ def coerce_hash(response, label:)
27
+ return {} if response.nil?
28
+ return symbolize(response) if response.is_a?(Hash)
29
+
30
+ parsed = JSON.parse(response.to_s)
31
+ raise ArgumentError, "#{label} payload must be a JSON object" unless parsed.is_a?(Hash)
32
+
33
+ symbolize(parsed)
34
+ rescue JSON::ParserError => e
35
+ raise ArgumentError, "Unable to parse #{label} payload: #{e.message}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "time"
5
4
 
5
+ require_relative "coercion"
6
6
  require_relative "fingerprint"
7
7
 
8
8
  module LlmCostTracker
@@ -17,7 +17,7 @@ module LlmCostTracker
17
17
  module_function
18
18
 
19
19
  def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
20
- payload = coerce_hash(response)
20
+ payload = Coercion.coerce_hash(response, label: "OpenAI Costs")
21
21
  buckets = Array(payload[:data])
22
22
  buckets.flat_map do |bucket|
23
23
  rows_for_bucket(bucket, authority: authority, row_type: row_type)
@@ -25,7 +25,7 @@ module LlmCostTracker
25
25
  end
26
26
 
27
27
  def rows_for_bucket(bucket, authority:, row_type:)
28
- bucket = symbolize(bucket)
28
+ bucket = Coercion.symbolize(bucket)
29
29
  start_time = bucket[:start_time]
30
30
  end_time = bucket[:end_time]
31
31
  return [] unless start_time && end_time
@@ -44,8 +44,8 @@ module LlmCostTracker
44
44
  end
45
45
 
46
46
  def row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:)
47
- result = symbolize(raw)
48
- amount = symbolize(result[:amount] || {})
47
+ result = Coercion.symbolize(raw)
48
+ amount = Coercion.symbolize(result[:amount] || {})
49
49
  billed_amount = amount[:value]
50
50
  return nil if billed_amount.nil?
51
51
 
@@ -93,19 +93,11 @@ module LlmCostTracker
93
93
  end
94
94
 
95
95
  def fingerprint_for(result, start_time:, end_time:)
96
- attributes = result.merge(start_time: normalized_epoch(start_time),
97
- end_time: normalized_epoch(end_time))
96
+ attributes = result.merge(start_time: Coercion.normalized_epoch(start_time),
97
+ end_time: Coercion.normalized_epoch(end_time))
98
98
  Fingerprint.compute(FINGERPRINT_KEYS, attributes)
99
99
  end
100
100
 
101
- def normalized_epoch(value)
102
- return value.to_i if value.is_a?(Numeric)
103
-
104
- Time.parse(value.to_s).utc.to_i
105
- rescue ArgumentError
106
- value.to_s
107
- end
108
-
109
101
  def epoch_to_date(value)
110
102
  return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
111
103
 
@@ -120,22 +112,6 @@ module LlmCostTracker
120
112
  end
121
113
  (time - 1).utc.to_date
122
114
  end
123
-
124
- def coerce_hash(response)
125
- return {} if response.nil?
126
- return symbolize(response) if response.is_a?(Hash)
127
-
128
- parsed = JSON.parse(response.to_s)
129
- raise ArgumentError, "OpenAI Costs payload must be a JSON object" unless parsed.is_a?(Hash)
130
-
131
- symbolize(parsed)
132
- rescue JSON::ParserError => e
133
- raise ArgumentError, "Unable to parse OpenAI Costs payload: #{e.message}"
134
- end
135
-
136
- def symbolize(hash)
137
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
138
- end
139
115
  end
140
116
  end
141
117
  end
@@ -4,18 +4,18 @@ module LlmCostTracker
4
4
  class Report
5
5
  class Formatter
6
6
  TOP_LIMIT = 5
7
- NAME_COLUMN_WIDTH = 28
8
- TOP_CALL_COLUMN_WIDTH = 32
7
+ MIN_COLUMN_WIDTH = 28
9
8
 
10
- def initialize(data)
9
+ def initialize(data, color: $stdout.tty?)
11
10
  @data = data
11
+ @color = color
12
12
  end
13
13
 
14
14
  def to_s
15
- lines = ["LLM Cost Report (last #{@data.days} days)", ""]
15
+ lines = [bold("LLM Cost Report (last #{@data.days} days)"), ""]
16
16
  append_summary(lines)
17
- append_cost_section(lines, "By provider", @data.cost_by_provider)
18
- append_cost_section(lines, "By model", @data.cost_by_model)
17
+ append_cost_section(lines, "By provider", @data.cost_by_provider) { |row| row.name.to_s }
18
+ append_cost_section(lines, "By model", @data.cost_by_model) { |row| row.name.to_s }
19
19
  append_tag_sections(lines)
20
20
  append_top_calls(lines)
21
21
  lines.join("\n")
@@ -27,36 +27,37 @@ module LlmCostTracker
27
27
  lines << "Total cost: #{money(@data.total_cost)}"
28
28
  lines << "Requests: #{@data.requests_count}"
29
29
  lines << "Avg latency: #{average_latency}"
30
- lines << "Unknown pricing: #{@data.unknown_pricing_count}"
30
+ lines << "Unknown pricing: #{colored_unknown_pricing(@data.unknown_pricing_count)}"
31
31
  end
32
32
 
33
- def append_cost_section(lines, title, rows)
33
+ def append_cost_section(lines, title, rows, &name_for)
34
34
  lines << ""
35
- lines << "#{title}:"
35
+ lines << bold("#{title}:")
36
36
  return lines << " none" if rows.empty?
37
37
 
38
- rows.first(TOP_LIMIT).each do |row|
39
- lines << " #{row.name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(row.total_cost)}"
38
+ visible = rows.first(TOP_LIMIT)
39
+ width = column_width(visible, &name_for)
40
+ visible.each do |row|
41
+ lines << " #{name_for.call(row).ljust(width)} #{money(row.total_cost)}"
40
42
  end
41
43
  end
42
44
 
43
45
  def append_tag_sections(lines)
44
46
  @data.cost_by_tags.each do |tag_key, rows|
45
- append_cost_section(lines, "By tag (#{tag_key})", rows)
47
+ append_cost_section(lines, "By tag (#{tag_key})", rows) { |row| row.name.to_s }
46
48
  end
47
49
  end
48
50
 
49
51
  def append_top_calls(lines)
50
- lines << ""
51
- lines << "Top expensive calls:"
52
- return lines << " none" if @data.top_calls.empty?
53
-
54
- @data.top_calls.first(TOP_LIMIT).each do |call|
55
- label = "#{call.provider}/#{call.model}"
56
- lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
52
+ append_cost_section(lines, "Top expensive calls", @data.top_calls) do |call|
53
+ "#{call.provider}/#{call.model}"
57
54
  end
58
55
  end
59
56
 
57
+ def column_width(rows, &name_for)
58
+ [MIN_COLUMN_WIDTH, rows.map { |row| name_for.call(row).length }.max.to_i].max
59
+ end
60
+
60
61
  def average_latency
61
62
  @data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
62
63
  end
@@ -64,6 +65,18 @@ module LlmCostTracker
64
65
  def money(value)
65
66
  "$#{format('%.6f', value.to_f)}"
66
67
  end
68
+
69
+ def colored_unknown_pricing(count)
70
+ return count.to_s unless @color
71
+
72
+ count.to_i.positive? ? "\e[33m#{count}\e[0m" : "\e[32m#{count}\e[0m"
73
+ end
74
+
75
+ def bold(text)
76
+ return text unless @color
77
+
78
+ "\e[1m#{text}\e[0m"
79
+ end
67
80
  end
68
81
  end
69
82
  end
@@ -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)