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
@@ -34,8 +34,8 @@ module LlmCostTracker
34
34
  private
35
35
 
36
36
  def period_rows(event)
37
- currency = currency_for(event)
38
- provider = provider_for(event)
37
+ currency = currency_from_snapshot(event.pricing_snapshot)
38
+ provider = event.provider.to_s
39
39
  Period::PERIODS.map do |period, name|
40
40
  {
41
41
  period: name,
@@ -61,8 +61,8 @@ module LlmCostTracker
61
61
 
62
62
  def call_rollups(events)
63
63
  events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
64
- currency = currency_for(event)
65
- provider = provider_for(event)
64
+ currency = currency_from_snapshot(event.pricing_snapshot)
65
+ provider = event.provider.to_s
66
66
  Period::PERIODS.each do |period, name|
67
67
  key = [name, Period.bucket(period, event.tracked_at), currency, provider]
68
68
  totals[key] += BigDecimal(event.total_cost.to_s)
@@ -118,20 +118,11 @@ module LlmCostTracker
118
118
  end
119
119
  end
120
120
 
121
- def currency_for(event)
122
- snapshot = event.respond_to?(:pricing_snapshot) ? event.pricing_snapshot : nil
123
- currency_from_snapshot(snapshot)
124
- end
125
-
126
121
  def currency_from_snapshot(snapshot)
127
122
  value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
128
123
  value.to_s.upcase
129
124
  end
130
125
 
131
- def provider_for(event)
132
- (event.respond_to?(:provider) ? event.provider : nil).to_s
133
- end
134
-
135
126
  def upsert_call_rollups(rows)
136
127
  LlmCostTracker::CallRollup.upsert_all(
137
128
  rows,
@@ -42,6 +42,17 @@ module LlmCostTracker
42
42
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
43
43
 
44
44
  columns = LlmCostTracker::CallLineItem.columns_hash
45
+ cache = @schema_capabilities
46
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
47
+
48
+ errors = compute_errors(connection, table_name, columns)
49
+ @schema_capabilities = { columns: columns, errors: errors }
50
+ errors
51
+ end
52
+
53
+ private
54
+
55
+ def compute_errors(connection, table_name, columns)
45
56
  errors = []
46
57
  missing = REQUIRED_COLUMNS - columns.keys
47
58
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
@@ -16,8 +16,20 @@ module LlmCostTracker
16
16
  table_name = LlmCostTracker::CallRollup.table_name
17
17
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
18
18
 
19
+ columns = LlmCostTracker::CallRollup.columns_hash
20
+ cache = @schema_capabilities
21
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
22
+
23
+ errors = compute_errors(connection, table_name, columns)
24
+ @schema_capabilities = { columns: columns, errors: errors }
25
+ errors
26
+ end
27
+
28
+ private
29
+
30
+ def compute_errors(connection, table_name, columns)
19
31
  errors = []
20
- missing = REQUIRED_COLUMNS - LlmCostTracker::CallRollup.columns_hash.keys
32
+ missing = REQUIRED_COLUMNS - columns.keys
21
33
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
22
34
  unless unique_period_index?(connection, table_name)
23
35
  errors << "missing unique index: period, period_start, currency, provider"
@@ -25,8 +37,6 @@ module LlmCostTracker
25
37
  errors
26
38
  end
27
39
 
28
- private
29
-
30
40
  def unique_period_index?(connection, table_name)
31
41
  connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
32
42
  end
@@ -19,6 +19,17 @@ module LlmCostTracker
19
19
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
20
20
 
21
21
  columns = LlmCostTracker::CallTag.columns_hash
22
+ cache = @schema_capabilities
23
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
24
+
25
+ errors = compute_errors(connection, table_name, columns)
26
+ @schema_capabilities = { columns: columns, errors: errors }
27
+ errors
28
+ end
29
+
30
+ private
31
+
32
+ def compute_errors(connection, table_name, columns)
22
33
  errors = []
23
34
  missing = REQUIRED_COLUMNS - columns.keys
24
35
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
@@ -47,10 +47,6 @@ module LlmCostTracker
47
47
  private_constant :REQUIRED_INDEXES
48
48
 
49
49
  class << self
50
- def current_schema?
51
- current_schema_errors.empty?
52
- end
53
-
54
50
  def current_schema_errors
55
51
  schema_capabilities.fetch(:current_schema_errors)
56
52
  end
@@ -28,15 +28,25 @@ module LlmCostTracker
28
28
  table_name = LlmCostTracker::Ingestion::InboxEntry.table_name
29
29
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
30
30
 
31
+ columns = LlmCostTracker::Ingestion::InboxEntry.columns_hash
32
+ cache = @schema_capabilities
33
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
34
+
35
+ errors = compute_errors(connection, table_name, columns)
36
+ @schema_capabilities = { columns: columns, errors: errors }
37
+ errors
38
+ end
39
+
40
+ private
41
+
42
+ def compute_errors(connection, table_name, columns)
31
43
  errors = []
32
- missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::InboxEntry.columns_hash.keys
44
+ missing = REQUIRED_COLUMNS - columns.keys
33
45
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
34
46
  errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
35
47
  errors
36
48
  end
37
49
 
38
- private
39
-
40
50
  def event_id_unique_index?(connection, table_name)
41
51
  connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
42
52
  end
@@ -23,15 +23,25 @@ module LlmCostTracker
23
23
  table_name = LlmCostTracker::Ingestion::Lease.table_name
24
24
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
25
25
 
26
+ columns = LlmCostTracker::Ingestion::Lease.columns_hash
27
+ cache = @schema_capabilities
28
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
29
+
30
+ errors = compute_errors(connection, table_name, columns)
31
+ @schema_capabilities = { columns: columns, errors: errors }
32
+ errors
33
+ end
34
+
35
+ private
36
+
37
+ def compute_errors(connection, table_name, columns)
26
38
  errors = []
27
- missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::Lease.columns_hash.keys
39
+ missing = REQUIRED_COLUMNS - columns.keys
28
40
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
29
41
  errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
30
42
  errors
31
43
  end
32
44
 
33
- private
34
-
35
45
  def name_unique_index?(connection, table_name)
36
46
  connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
37
47
  end
@@ -7,10 +7,10 @@ module LlmCostTracker
7
7
  module Schema
8
8
  module ProviderInvoiceImports
9
9
  REQUIRED_COLUMNS = %w[
10
- source cursor window_start window_end state last_error
10
+ source provider cursor window_start window_end state last_error
11
11
  rows_imported started_at finished_at
12
12
  ].freeze
13
- SOURCE_STARTED_AT_INDEX = %i[source started_at].freeze
13
+ SOURCE_PROVIDER_STARTED_AT_INDEX = %i[source provider started_at].freeze
14
14
 
15
15
  class << self
16
16
  def current_schema_errors
@@ -19,25 +19,35 @@ module LlmCostTracker
19
19
  table_name = LlmCostTracker::ProviderInvoiceImport.table_name
20
20
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
21
 
22
- errors = []
23
- errors.concat(column_errors)
24
- errors.concat(index_errors(connection, table_name))
22
+ columns = LlmCostTracker::ProviderInvoiceImport.columns_hash
23
+ cache = @schema_capabilities
24
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
25
+
26
+ errors = compute_errors(connection, table_name, columns)
27
+ @schema_capabilities = { columns: columns, errors: errors }
25
28
  errors
26
29
  end
27
30
 
28
31
  private
29
32
 
30
- def column_errors
31
- missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoiceImport.columns_hash.keys
33
+ def compute_errors(connection, table_name, columns)
34
+ errors = []
35
+ errors.concat(column_errors(columns))
36
+ errors.concat(index_errors(connection, table_name))
37
+ errors
38
+ end
39
+
40
+ def column_errors(columns)
41
+ missing = REQUIRED_COLUMNS - columns.keys
32
42
  return [] if missing.empty?
33
43
 
34
44
  ["missing columns: #{missing.join(', ')}"]
35
45
  end
36
46
 
37
47
  def index_errors(connection, table_name)
38
- return [] if connection.index_exists?(table_name, SOURCE_STARTED_AT_INDEX)
48
+ return [] if connection.index_exists?(table_name, SOURCE_PROVIDER_STARTED_AT_INDEX)
39
49
 
40
- ["missing index: source, started_at"]
50
+ ["missing index: source, provider, started_at"]
41
51
  end
42
52
  end
43
53
  end
@@ -19,27 +19,32 @@ module LlmCostTracker
19
19
  table_name = LlmCostTracker::ProviderInvoice.table_name
20
20
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
21
 
22
- errors = []
23
- errors.concat(column_errors)
24
- errors.concat(metadata_type_errors(connection))
25
- errors.concat(index_errors(connection, table_name))
22
+ columns = LlmCostTracker::ProviderInvoice.columns_hash
23
+ cache = @schema_capabilities
24
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
25
+
26
+ errors = compute_errors(connection, table_name, columns)
27
+ @schema_capabilities = { columns: columns, errors: errors }
26
28
  errors
27
29
  end
28
30
 
29
31
  private
30
32
 
31
- def column_errors
32
- missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoice.columns_hash.keys
33
+ def compute_errors(connection, table_name, columns)
34
+ errors = []
35
+ errors.concat(column_errors(columns))
36
+ errors.concat(Adapter.json_column_errors(columns["metadata"], connection, "metadata"))
37
+ errors.concat(index_errors(connection, table_name))
38
+ errors
39
+ end
40
+
41
+ def column_errors(columns)
42
+ missing = REQUIRED_COLUMNS - columns.keys
33
43
  return [] if missing.empty?
34
44
 
35
45
  ["missing columns: #{missing.join(', ')}"]
36
46
  end
37
47
 
38
- def metadata_type_errors(connection)
39
- metadata = LlmCostTracker::ProviderInvoice.columns_hash["metadata"]
40
- Adapter.json_column_errors(metadata, connection, "metadata")
41
- end
42
-
43
48
  def index_errors(connection, table_name)
44
49
  errors = []
45
50
  unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
@@ -48,8 +53,18 @@ module LlmCostTracker
48
53
  unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
49
54
  errors << "missing index: source, currency, period_start"
50
55
  end
56
+ if Adapter.postgresql?(connection) && !gin_metadata_index?(connection, table_name)
57
+ errors << "missing GIN index on metadata " \
58
+ "(run bin/rails generate llm_cost_tracker:upgrade_provider_invoices_metadata_index)"
59
+ end
51
60
  errors
52
61
  end
62
+
63
+ def gin_metadata_index?(connection, table_name)
64
+ connection.indexes(table_name).any? do |index|
65
+ index.columns == ["metadata"] && index.using.to_s == "gin"
66
+ end
67
+ end
53
68
  end
54
69
  end
55
70
  end
@@ -11,27 +11,34 @@ module LlmCostTracker
11
11
  module Ledger
12
12
  class Store
13
13
  class << self
14
- def insert_many(events)
14
+ def insert(events, skip_existence_check: false)
15
15
  events = Array(events)
16
- return [] if events.empty?
16
+ return if events.empty?
17
17
 
18
- insertable = insertable_events(events)
18
+ insertable = skip_existence_check ? events : insertable_events(events)
19
+ return unless insertable.any?
19
20
 
20
- if insertable.any?
21
- LlmCostTracker::Call.transaction do
22
- rows = insertable.map { |event| attributes_for(event) }
23
- LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
24
- call_ids = call_ids_for(insertable)
25
- insert_line_items(insertable, call_ids)
26
- insert_call_tags(insertable, call_ids)
27
- end
28
- increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
21
+ LlmCostTracker::Call.transaction do
22
+ rows = insertable.map { |event| attributes_for(event) }
23
+ call_ids = insert_calls_returning_ids(rows, insertable)
24
+ insert_line_items(insertable, call_ids)
25
+ insert_call_tags(insertable, call_ids)
29
26
  end
30
- events
27
+ increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
31
28
  end
32
29
 
33
30
  private
34
31
 
32
+ def insert_calls_returning_ids(rows, insertable)
33
+ if LlmCostTracker::Call.connection.supports_insert_returning?
34
+ result = LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: %i[id event_id])
35
+ result.rows.to_h { |id, event_id| [event_id, id] }
36
+ else
37
+ LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
38
+ call_ids_for(insertable)
39
+ end
40
+ end
41
+
35
42
  def attributes_for(event)
36
43
  attributes = {
37
44
  event_id: event.event_id,
@@ -110,7 +117,7 @@ module LlmCostTracker
110
117
  {
111
118
  llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
112
119
  key: key.to_s,
113
- value: tag_row_value(value)
120
+ value: Tags::Encoding.encode(value)
114
121
  }
115
122
  end
116
123
  end
@@ -119,10 +126,6 @@ module LlmCostTracker
119
126
  LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
120
127
  end
121
128
 
122
- def tag_row_value(value)
123
- Tags::Encoding.encode(value)
124
- end
125
-
126
129
  def stored_details(details)
127
130
  (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
128
131
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../schema/adapter"
4
3
  require_relative "encoding"
5
4
 
6
5
  module LlmCostTracker
@@ -14,3 +14,16 @@ require_relative "ledger/rollups/upsert_sql"
14
14
  require_relative "ledger/rollups"
15
15
  require_relative "ledger/store"
16
16
  require_relative "ledger/period/totals"
17
+
18
+ module LlmCostTracker
19
+ module Ledger
20
+ module Schema
21
+ CORE_SCHEMAS = [
22
+ [Calls, "llm_cost_tracker_calls"],
23
+ [CallLineItems, "llm_cost_tracker_call_line_items"],
24
+ [CallTags, "llm_cost_tracker_call_tags"]
25
+ ].freeze
26
+ CACHE_ROLLUPS_SCHEMA = [CallRollups, "llm_cost_tracker_call_rollups"].freeze
27
+ end
28
+ end
29
+ end
@@ -9,10 +9,6 @@ module LlmCostTracker
9
9
  log(:debug, message)
10
10
  end
11
11
 
12
- def info(message)
13
- log(:info, message)
14
- end
15
-
16
12
  def warn(message)
17
13
  log(:warn, message)
18
14
  end
@@ -6,6 +6,7 @@ require "stringio"
6
6
  require "uri"
7
7
 
8
8
  require_relative "../logging"
9
+ require_relative "../capture/sse"
9
10
  require_relative "../capture/stream"
10
11
  require_relative "../timing"
11
12
 
@@ -18,16 +19,25 @@ module LlmCostTracker
18
19
  end
19
20
 
20
21
  def call(request_env)
21
- return @app.call(request_env) unless LlmCostTracker.configuration.enabled
22
+ return @app.call(request_env) unless enabled?
22
23
 
23
24
  request_url = request_env.url.to_s
24
25
  request_body = read_body(request_env.body)
25
26
  parser = Parsers.find_for(request_url)
26
- streaming = parser&.streaming_request?(request_url, request_body)
27
- request_body = inject_stream_usage_flag(request_env, parser, request_url) if streaming
27
+ request_parsed = parser ? safe_json_parse(request_body) : nil
28
+ streaming = parser&.streaming_request?(request_url, request_parsed)
29
+ if streaming
30
+ request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
31
+ end
28
32
  stream_buffer = install_stream_tap(request_env) if streaming
29
33
 
30
- Tracker.enforce_budget! if parser
34
+ if parser
35
+ Tracker.enforce_budget!(
36
+ provider: parser.provider_for(request_url),
37
+ model: parser.model_for(request_url, request_parsed),
38
+ request: request_parsed
39
+ )
40
+ end
31
41
  context_tags, metadata = tag_snapshot(request_env) if parser
32
42
  started_at = LlmCostTracker::Timing.now_monotonic
33
43
 
@@ -40,6 +50,26 @@ module LlmCostTracker
40
50
 
41
51
  private
42
52
 
53
+ def enabled?
54
+ return @enabled if defined?(@enabled)
55
+
56
+ @enabled = LlmCostTracker.configuration.enabled
57
+ end
58
+
59
+ def safe_json_parse(body)
60
+ return {} if body.nil? || body.empty?
61
+
62
+ JSON.parse(body)
63
+ rescue JSON::ParserError
64
+ {}
65
+ end
66
+
67
+ def auto_enable_stream_usage?
68
+ return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
69
+
70
+ @auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
71
+ end
72
+
43
73
  def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
44
74
  stream_buffer:, context_tags:, metadata:, started_at:)
45
75
  response_received = false
@@ -63,16 +93,15 @@ module LlmCostTracker
63
93
  raise
64
94
  end
65
95
 
66
- def inject_stream_usage_flag(request_env, parser, request_url)
67
- body_string = read_body(request_env.body)
68
- return body_string unless LlmCostTracker.configuration.auto_enable_stream_usage
69
- return body_string unless parser&.auto_enable_stream_usage?(request_url)
96
+ def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
97
+ return nil unless auto_enable_stream_usage?
98
+ return nil unless parser&.auto_enable_stream_usage?(request_url)
70
99
 
71
- body = JSON.parse(body_string)
72
- return body_string if body["stream_options"].is_a?(Hash) && body["stream_options"].key?("include_usage")
100
+ stream_options = request_parsed["stream_options"]
101
+ return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
73
102
 
74
- body["stream_options"] = (body["stream_options"] || {}).merge("include_usage" => true)
75
- new_body = body.to_json
103
+ request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
104
+ new_body = request_parsed.to_json
76
105
  request_env.body = new_body
77
106
  new_body
78
107
  end
@@ -80,9 +109,9 @@ module LlmCostTracker
80
109
  def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
81
110
  context_tags:, metadata:, error:)
82
111
  request = parser.safe_json_parse(request_body)
83
- capture = UsageCapture.build(
112
+ event = Event.build(
84
113
  provider: parser.provider_for(request_url),
85
- model: request["model"] || UsageCapture::UNKNOWN_MODEL,
114
+ model: request["model"] || Event::UNKNOWN_MODEL,
86
115
  token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
87
116
  stream: true,
88
117
  usage_source: :unknown
@@ -92,7 +121,7 @@ module LlmCostTracker
92
121
  stream_interrupted_error: "#{error.class}: #{error.message}"
93
122
  )
94
123
  Tracker.record(
95
- capture: capture,
124
+ event: event,
96
125
  latency_ms: latency_ms,
97
126
  metadata: merged_metadata,
98
127
  context_tags: context_tags
@@ -125,7 +154,7 @@ module LlmCostTracker
125
154
  return unless parsed
126
155
 
127
156
  Tracker.record(
128
- capture: parsed,
157
+ event: parsed,
129
158
  latency_ms: latency_ms,
130
159
  metadata: metadata,
131
160
  context_tags: context_tags
@@ -173,7 +202,7 @@ module LlmCostTracker
173
202
  )
174
203
  end
175
204
 
176
- events = overflowed ? [] : Parsers::SSE.parse(body)
205
+ events = overflowed ? [] : Capture::SSE.parse(body)
177
206
  parser.parse_stream(
178
207
  request_url: request_url,
179
208
  request_body: request_body,