llm_cost_tracker 0.8.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -13,6 +13,9 @@ require_relative "doctor/pricing_snapshot_drift_check"
13
13
 
14
14
  module LlmCostTracker
15
15
  class Doctor
16
+ autoload :InvoiceReconciliationCheck, "llm_cost_tracker/doctor/invoice_reconciliation_check"
17
+ autoload :CaptureVerifier, "llm_cost_tracker/doctor/capture_verifier"
18
+
16
19
  class << self
17
20
  def call
18
21
  new.checks
@@ -33,7 +36,7 @@ module LlmCostTracker
33
36
  [
34
37
  configuration_check,
35
38
  capture_check,
36
- *integration_checks,
39
+ *LlmCostTracker::Integrations.checks,
37
40
  active_record_check,
38
41
  table_check,
39
42
  column_check,
@@ -41,10 +44,10 @@ module LlmCostTracker
41
44
  table: "llm_cost_tracker_call_line_items").call,
42
45
  SchemaCheck.new(name: "call tags", schema: Ledger::Schema::CallTags,
43
46
  table: "llm_cost_tracker_call_tags").call,
44
- SchemaCheck.new(name: "provider invoices", schema: Ledger::Schema::ProviderInvoices,
45
- table: "llm_cost_tracker_provider_invoices").call,
47
+ *reconciliation_schema_checks,
46
48
  CostDriftCheck.new.call,
47
49
  PricingSnapshotDriftCheck.new.call,
50
+ *reconciliation_invoice_check,
48
51
  LegacyBillingStatusCheck.new.call,
49
52
  LegacyAuditCheck.new.call,
50
53
  call_rollups_check,
@@ -56,6 +59,22 @@ module LlmCostTracker
56
59
 
57
60
  private
58
61
 
62
+ def reconciliation_schema_checks
63
+ return [] unless LlmCostTracker.reconciliation_enabled?
64
+
65
+ Reconciliation::SCHEMA_TABLES.map do |schema, table|
66
+ SchemaCheck.new(name: table.delete_prefix("llm_cost_tracker_").tr("_", " "),
67
+ schema: schema, table: table,
68
+ optional: false, install_command: "llm_cost_tracker:reconciliation").call
69
+ end.compact
70
+ end
71
+
72
+ def reconciliation_invoice_check
73
+ return [] unless LlmCostTracker.reconciliation_enabled?
74
+
75
+ Array(InvoiceReconciliationCheck.new.call)
76
+ end
77
+
59
78
  def configuration_check
60
79
  config = LlmCostTracker.configuration
61
80
  Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
@@ -82,12 +101,6 @@ module LlmCostTracker
82
101
  )
83
102
  end
84
103
 
85
- def integration_checks
86
- LlmCostTracker::Integrations.checks.map do |check|
87
- Check.new(check.status, check.name.to_s, check.message)
88
- end
89
- end
90
-
91
104
  def active_record_check
92
105
  return Check.new(:ok, "active_record", "available") if active_record_available?
93
106
 
@@ -120,9 +133,10 @@ module LlmCostTracker
120
133
 
121
134
  def call_rollups_check
122
135
  return unless llm_cost_tracker_calls_table?
136
+ return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
123
137
 
124
138
  errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
125
- return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
139
+ return rollups_drift_check if errors.empty?
126
140
 
127
141
  Check.new(
128
142
  :error,
@@ -131,6 +145,52 @@ module LlmCostTracker
131
145
  )
132
146
  end
133
147
 
148
+ ROLLUPS_DRIFT_TOLERANCE_PERCENT = 1.0
149
+ private_constant :ROLLUPS_DRIFT_TOLERANCE_PERCENT
150
+
151
+ def rollups_drift_check
152
+ drift_window = Time.now.utc.beginning_of_day
153
+ calls_total = LlmCostTracker::Call
154
+ .where(tracked_at: drift_window..)
155
+ .where.not(total_cost: nil)
156
+ .sum(:total_cost)
157
+ rollup_total = LlmCostTracker::CallRollup
158
+ .where(period: "day", period_start: drift_window.to_date)
159
+ .sum(:total_cost)
160
+ return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if calls_total.zero?
161
+
162
+ drift_percent = ((calls_total - rollup_total).abs * 100.0 / calls_total)
163
+ if drift_percent > ROLLUPS_DRIFT_TOLERANCE_PERCENT
164
+ return Check.new(
165
+ :warn, "call rollups",
166
+ "rollups drift detected: today's calls SUM=#{calls_total} vs rollups SUM=#{rollup_total} " \
167
+ "(#{drift_percent.round(2)}% > #{ROLLUPS_DRIFT_TOLERANCE_PERCENT}% threshold). " \
168
+ "Cached budget reads may understate spend until a rebuild."
169
+ )
170
+ end
171
+
172
+ Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists")
173
+ rescue StandardError => e
174
+ Check.new(:warn, "call rollups", "rollups drift check failed: #{e.class}: #{e.message}")
175
+ end
176
+
177
+ def live_rollups_check
178
+ if Probe.table_exists?("llm_cost_tracker_call_rollups")
179
+ Check.new(
180
+ :warn,
181
+ "call rollups",
182
+ "cache_rollups=false but llm_cost_tracker_call_rollups exists. " \
183
+ "Set config.cache_rollups = true to keep budget reads on the rollups fast path or drop the table."
184
+ )
185
+ else
186
+ Check.new(
187
+ :ok,
188
+ "call rollups",
189
+ "cache_rollups=false; budget reads aggregate from llm_cost_tracker_calls directly"
190
+ )
191
+ end
192
+ end
193
+
134
194
  def calls_check
135
195
  return unless llm_cost_tracker_calls_table?
136
196
 
@@ -140,16 +200,14 @@ module LlmCostTracker
140
200
  count = snapshot.tracked_call_count.to_i
141
201
  return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
142
202
 
143
- latest_at = snapshot.latest_tracked_at
144
- latest_at = latest_at.to_time if latest_at.respond_to?(:to_time)
145
- latest = latest_at&.utc&.iso8601
203
+ latest = snapshot.latest_tracked_at.to_time.utc.iso8601
146
204
  Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
147
205
  end
148
206
 
149
207
  def active_record_available?
150
208
  LlmCostTracker::Call.connection
151
209
  true
152
- rescue LoadError, StandardError
210
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
153
211
  false
154
212
  end
155
213
 
@@ -8,5 +8,13 @@ require "rack/files"
8
8
  module LlmCostTracker
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace LlmCostTracker
11
+
12
+ initializer "llm_cost_tracker.filter_parameters" do |app|
13
+ app.config.filter_parameters += %i[tag tag_value]
14
+ end
15
+
16
+ initializer "llm_cost_tracker.dashboard_setup_state" do |app|
17
+ app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
18
+ end
11
19
  end
12
20
  end
@@ -6,13 +6,14 @@ module LlmCostTracker
6
6
  class InvalidFilterError < Error; end
7
7
 
8
8
  class BudgetExceededError < Error
9
- attr_reader :total, :budget, :budget_type, :last_event
9
+ attr_reader :total, :budget, :budget_type, :last_event, :stage
10
10
 
11
- def initialize(budget:, budget_type:, total:, last_event: nil)
11
+ def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend)
12
12
  @total = total
13
13
  @budget = budget
14
14
  @budget_type = budget_type
15
15
  @last_event = last_event
16
+ @stage = stage
16
17
 
17
18
  super(
18
19
  "LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded: " \
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "pricing"
4
+ require_relative "billing/line_item"
5
+
3
6
  module LlmCostTracker
4
7
  Event = Data.define(
5
8
  :event_id,
@@ -22,6 +25,46 @@ module LlmCostTracker
22
25
  :pricing_snapshot,
23
26
  :line_items
24
27
  ) do
28
+ def self.batch_from_pricing_mode?(pricing_mode)
29
+ pricing_mode.to_s.split("_").include?("batch")
30
+ end
31
+
32
+ def self.build(**attributes)
33
+ pricing_mode = Pricing.normalize_mode(attributes[:pricing_mode])
34
+ token_usage = attributes.fetch(:token_usage)
35
+ batch = attributes[:batch].nil? ? batch_from_pricing_mode?(pricing_mode) : attributes[:batch]
36
+ line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items], token_usage)
37
+
38
+ new(
39
+ event_id: attributes[:event_id],
40
+ provider: attributes.fetch(:provider).to_s,
41
+ model: attributes.fetch(:model).to_s.strip.presence || Event::UNKNOWN_MODEL,
42
+ token_usage: token_usage,
43
+ pricing_mode: pricing_mode,
44
+ cost: attributes[:cost],
45
+ tags: attributes[:tags],
46
+ latency_ms: attributes[:latency_ms],
47
+ stream: attributes[:stream] || false,
48
+ usage_source: attributes[:usage_source],
49
+ provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
50
+ provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
51
+ provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
52
+ provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
53
+ batch: batch,
54
+ tracked_at: attributes[:tracked_at],
55
+ cost_status: attributes[:cost_status],
56
+ pricing_snapshot: attributes[:pricing_snapshot],
57
+ line_items: line_items
58
+ )
59
+ end
60
+
61
+ def self.resolve_line_items(service_items, token_usage)
62
+ service_line_items = Array(service_items).map do |item|
63
+ item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
64
+ end
65
+ Billing::LineItem.from_token_usage(token_usage) + service_line_items
66
+ end
67
+
25
68
  def total_cost
26
69
  cost&.fetch(:total_cost, nil)
27
70
  end
@@ -29,10 +72,14 @@ module LlmCostTracker
29
72
  def to_h
30
73
  super.merge(
31
74
  token_usage: token_usage.to_h,
32
- cost: cost&.to_h,
75
+ cost: cost && cost.to_h.transform_values { |v| v.is_a?(BigDecimal) ? v.to_f : v },
33
76
  tags: tags ? tags.to_h : {},
34
77
  line_items: (line_items || []).map(&:to_h)
35
78
  )
36
79
  end
37
80
  end
81
+
82
+ class Event
83
+ UNKNOWN_MODEL = "unknown"
84
+ end
38
85
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class AsyncIngestionGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
14
+ "Required when config.ingestion = :async."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "create_llm_cost_tracker_async_ingestion.rb.erb",
19
+ "db/migrate/create_llm_cost_tracker_async_ingestion.rb"
20
+ )
21
+ end
22
+
23
+ def warn_about_config_flag
24
+ say(<<~MSG, :yellow)
25
+ After migrating, set the following in config/initializers/llm_cost_tracker.rb:
26
+
27
+ LlmCostTracker.configure do |config|
28
+ config.ingestion = :async
29
+ end
30
+
31
+ Without it the async inbox tables stay unused and Tracker keeps writing
32
+ inline. The doctor check warns about unused async ingestion tables.
33
+ MSG
34
+ end
35
+
36
+ private
37
+
38
+ def migration_version
39
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class CallRollupsGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
14
+ "Required when config.cache_rollups = true."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "create_llm_cost_tracker_call_rollups.rb.erb",
19
+ "db/migrate/create_llm_cost_tracker_call_rollups.rb"
20
+ )
21
+ end
22
+
23
+ def warn_about_config_flag
24
+ say(<<~MSG, :yellow)
25
+ After migrating, set the following in config/initializers/llm_cost_tracker.rb:
26
+
27
+ LlmCostTracker.configure do |config|
28
+ config.cache_rollups = true
29
+ end
30
+
31
+ Without it Tracker keeps reading budget totals as live SUM aggregates over
32
+ llm_cost_tracker_calls. The doctor check warns about an unused rollups table.
33
+ MSG
34
+ end
35
+
36
+ private
37
+
38
+ def migration_version
39
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -26,24 +26,33 @@ module LlmCostTracker
26
26
  end
27
27
 
28
28
  def create_initializer
29
- template(
30
- "initializer.rb.erb",
31
- "config/initializers/llm_cost_tracker.rb"
32
- )
29
+ destination = "config/initializers/llm_cost_tracker.rb"
30
+ return if File.exist?(File.join(destination_root, destination))
31
+
32
+ template("initializer.rb.erb", destination)
33
33
  end
34
34
 
35
35
  def create_prices_file
36
36
  return unless options[:prices]
37
37
 
38
- invoke "llm_cost_tracker:prices"
38
+ require_relative "prices_generator"
39
+ invoke LlmCostTracker::Generators::PricesGenerator
39
40
  end
40
41
 
41
42
  def mount_engine
42
43
  return unless options[:dashboard]
43
44
 
44
- add_engine_require
45
- route %(mount LlmCostTracker::Engine => "/llm-costs")
46
- say "Mount /llm-costs behind your app's admin auth before deploying.", :yellow
45
+ say(<<~MSG, :yellow)
46
+ The LLM Cost Tracker dashboard ships without authentication.
47
+ Mount it in config/routes.rb behind your app's admin auth, e.g.:
48
+
49
+ authenticate :admin do
50
+ mount LlmCostTracker::Engine => "/llm-costs"
51
+ end
52
+
53
+ The generator does NOT add a route automatically — leaving the dashboard
54
+ unauthenticated would expose spend, tags, and provider IDs to anyone.
55
+ MSG
47
56
  end
48
57
 
49
58
  private
@@ -51,24 +60,6 @@ module LlmCostTracker
51
60
  def migration_version
52
61
  "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
53
62
  end
54
-
55
- def add_engine_require
56
- return unless File.exist?("config/application.rb")
57
-
58
- contents = File.read("config/application.rb")
59
- return if contents.include?(%(require "llm_cost_tracker/engine"))
60
-
61
- unless contents.include?(%(require "rails/all"\n))
62
- prepend_to_file("config/application.rb", %(require "llm_cost_tracker/engine"\n))
63
- return
64
- end
65
-
66
- inject_into_file(
67
- "config/application.rb",
68
- %(require "llm_cost_tracker/engine"\n),
69
- after: %(require "rails/all"\n)
70
- )
71
- end
72
63
  end
73
64
  end
74
65
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class ReconciliationGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates the optional invoice reconciliation tables. Requires provider admin/org-level API keys."
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "create_llm_cost_tracker_reconciliation.rb.erb",
18
+ "db/migrate/create_llm_cost_tracker_reconciliation.rb"
19
+ )
20
+ end
21
+
22
+ def warn_about_admin_keys
23
+ say "Reconciliation requires admin/org-level API keys (OpenAI sk-admin-..., Anthropic admin keys, " \
24
+ "GCP billing.viewer service accounts). Do NOT use the runtime inference key.", :yellow
25
+ end
26
+
27
+ private
28
+
29
+ def migration_version
30
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
4
+ t.string :event_id, null: false
5
+ t.decimal :total_cost, precision: 20, scale: 8
6
+ t.datetime :tracked_at, null: false
7
+ t.text :payload, null: false, limit: 16.megabytes
8
+ t.datetime :locked_at
9
+ t.string :locked_by
10
+ t.integer :attempts, null: false, default: 0
11
+ t.text :last_error, limit: 16.megabytes
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ create_table :llm_cost_tracker_ingestion_leases do |t|
17
+ t.string :name, null: false
18
+ t.string :locked_by
19
+ t.datetime :locked_until
20
+
21
+ t.timestamps
22
+ end
23
+
24
+ add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
25
+ add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
26
+ add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
27
+ add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :llm_cost_tracker_call_rollups do |t|
4
+ t.string :period, null: false
5
+ t.date :period_start, null: false
6
+ t.string :currency, null: false, default: "USD"
7
+ t.string :provider, null: false, default: ""
8
+ t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :llm_cost_tracker_call_rollups, [:period, :period_start, :currency, :provider], unique: true
14
+ end
15
+ end
@@ -34,36 +34,6 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
34
34
  t.timestamps
35
35
  end
36
36
 
37
- create_table :llm_cost_tracker_call_rollups do |t|
38
- t.string :period, null: false
39
- t.date :period_start, null: false
40
- t.string :currency, null: false, default: "USD"
41
- t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
42
-
43
- t.timestamps
44
- end
45
-
46
- create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
47
- t.string :event_id, null: false
48
- t.decimal :total_cost, precision: 20, scale: 8
49
- t.datetime :tracked_at, null: false
50
- t.text :payload, null: false
51
- t.datetime :locked_at
52
- t.string :locked_by
53
- t.integer :attempts, null: false, default: 0
54
- t.text :last_error
55
-
56
- t.timestamps
57
- end
58
-
59
- create_table :llm_cost_tracker_ingestion_leases do |t|
60
- t.string :name, null: false
61
- t.string :locked_by
62
- t.datetime :locked_until
63
-
64
- t.timestamps
65
- end
66
-
67
37
  create_table :llm_cost_tracker_call_line_items do |t|
68
38
  t.references :llm_cost_tracker_call,
69
39
  null: false,
@@ -107,25 +77,6 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
107
77
  t.text :value, null: false
108
78
  end
109
79
 
110
- create_table :llm_cost_tracker_provider_invoices do |t|
111
- t.string :source, null: false
112
- t.date :period_start, null: false
113
- t.date :period_end, null: false
114
- t.string :external_id, null: false
115
- t.decimal :billed_amount, precision: 20, scale: 8
116
- t.string :currency, null: false, default: "USD"
117
- if postgresql?
118
- t.jsonb :metadata, null: false, default: {}
119
- elsif mysql?
120
- t.json :metadata, null: false
121
- else
122
- raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
123
- end
124
- t.datetime :imported_at, null: false
125
-
126
- t.timestamps
127
- end
128
-
129
80
  add_index :llm_cost_tracker_calls, :event_id, unique: true
130
81
  add_index :llm_cost_tracker_calls, :tracked_at
131
82
  add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
@@ -133,16 +84,12 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
133
84
  add_index :llm_cost_tracker_calls, :cost_status
134
85
  add_index :llm_cost_tracker_calls, :provider_response_id
135
86
  add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
136
- add_index :llm_cost_tracker_call_line_items, :kind
137
87
  add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
138
- add_index :llm_cost_tracker_call_tags, :key
139
- add_index :llm_cost_tracker_call_rollups, [:period, :period_start, :currency], unique: true
140
- add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
141
- add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
142
- add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
143
- add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
144
- add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true
145
- add_index :llm_cost_tracker_provider_invoices, [:source, :period_start]
88
+ if postgresql?
89
+ add_index :llm_cost_tracker_call_tags, [:key, :value]
90
+ elsif mysql?
91
+ add_index :llm_cost_tracker_call_tags, [:key, :value], length: { value: 191 }
92
+ end
146
93
  end
147
94
 
148
95
  private
@@ -0,0 +1,60 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :llm_cost_tracker_provider_invoices, if_not_exists: true do |t|
6
+ t.string :source, null: false
7
+ t.date :period_start, null: false
8
+ t.date :period_end, null: false
9
+ t.string :external_id, null: false
10
+ t.decimal :billed_amount, precision: 20, scale: 8
11
+ t.string :currency, null: false, default: "USD"
12
+ if postgresql?
13
+ t.jsonb :metadata, null: false, default: {}
14
+ elsif mysql?
15
+ t.json :metadata, null: false
16
+ else
17
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
18
+ end
19
+ t.datetime :imported_at, null: false
20
+
21
+ t.timestamps
22
+ end
23
+
24
+ create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
25
+ t.string :source, null: false
26
+ t.string :provider, null: false, default: ""
27
+ t.string :cursor
28
+ t.date :window_start
29
+ t.date :window_end
30
+ t.string :state, null: false
31
+ t.text :last_error
32
+ t.integer :rows_imported, null: false, default: 0
33
+ t.datetime :started_at, null: false
34
+ t.datetime :finished_at
35
+
36
+ t.timestamps
37
+ end
38
+
39
+ add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true,
40
+ if_not_exists: true
41
+ add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
42
+ if_not_exists: true
43
+ if postgresql?
44
+ add_index :llm_cost_tracker_provider_invoices, :metadata, using: :gin,
45
+ if_not_exists: true
46
+ end
47
+ add_index :llm_cost_tracker_provider_invoice_imports, %i[source provider started_at],
48
+ if_not_exists: true
49
+ end
50
+
51
+ private
52
+
53
+ def postgresql?
54
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
55
+ end
56
+
57
+ def mysql?
58
+ LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
59
+ end
60
+ end