llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -2,23 +2,18 @@
2
2
 
3
3
  require_relative "ledger"
4
4
  require_relative "doctor/check"
5
+ require_relative "doctor/probe"
5
6
  require_relative "doctor/ingestion_check"
7
+ require_relative "doctor/legacy_audit_check"
8
+ require_relative "doctor/legacy_billing_status_check"
6
9
  require_relative "doctor/price_check"
7
- require_relative "generators/llm_cost_tracker/add_token_usage_generator"
10
+ require_relative "doctor/schema_check"
11
+ require_relative "doctor/cost_drift_check"
12
+ require_relative "doctor/pricing_snapshot_drift_check"
8
13
 
9
14
  module LlmCostTracker
10
15
  class Doctor
11
- COLUMN_GENERATORS = {
12
- "event_id" => "bin/rails generate llm_cost_tracker:add_ingestion",
13
- "latency_ms" => "bin/rails generate llm_cost_tracker:add_latency_ms",
14
- "stream" => "bin/rails generate llm_cost_tracker:add_streaming",
15
- "usage_source" => "bin/rails generate llm_cost_tracker:add_streaming",
16
- "provider_response_id" => "bin/rails generate llm_cost_tracker:add_provider_response_id"
17
- }.merge(
18
- Generators::AddTokenUsageGenerator::COLUMN_NAMES.to_h do |column|
19
- [column, "bin/rails generate llm_cost_tracker:add_token_usage"]
20
- end
21
- ).freeze
16
+ autoload :InvoiceReconciliationCheck, "llm_cost_tracker/doctor/invoice_reconciliation_check"
22
17
 
23
18
  class << self
24
19
  def call
@@ -44,7 +39,17 @@ module LlmCostTracker
44
39
  active_record_check,
45
40
  table_check,
46
41
  column_check,
47
- period_totals_check,
42
+ SchemaCheck.new(name: "call line items", schema: Ledger::Schema::CallLineItems,
43
+ table: "llm_cost_tracker_call_line_items").call,
44
+ SchemaCheck.new(name: "call tags", schema: Ledger::Schema::CallTags,
45
+ table: "llm_cost_tracker_call_tags").call,
46
+ *reconciliation_schema_checks,
47
+ CostDriftCheck.new.call,
48
+ PricingSnapshotDriftCheck.new.call,
49
+ *reconciliation_invoice_check,
50
+ LegacyBillingStatusCheck.new.call,
51
+ LegacyAuditCheck.new.call,
52
+ call_rollups_check,
48
53
  IngestionCheck.new.call,
49
54
  PriceCheck.new.call,
50
55
  calls_check
@@ -53,6 +58,26 @@ module LlmCostTracker
53
58
 
54
59
  private
55
60
 
61
+ def reconciliation_schema_checks
62
+ return [] unless LlmCostTracker.reconciliation_enabled?
63
+
64
+ LlmCostTracker.const_get(:Reconciliation) # autoload reconciliation + its ledger schemas
65
+ Reconciliation::SCHEMA_TABLES.map do |schema, table|
66
+ SchemaCheck.new(name: humanize_table(table), schema: schema, table: table,
67
+ optional: false, install_command: "llm_cost_tracker:reconciliation").call
68
+ end.compact
69
+ end
70
+
71
+ def humanize_table(table)
72
+ table.delete_prefix("llm_cost_tracker_").tr("_", " ")
73
+ end
74
+
75
+ def reconciliation_invoice_check
76
+ return [] unless LlmCostTracker.reconciliation_enabled?
77
+
78
+ Array(InvoiceReconciliationCheck.new.call)
79
+ end
80
+
56
81
  def configuration_check
57
82
  config = LlmCostTracker.configuration
58
83
  Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
@@ -68,7 +93,7 @@ module LlmCostTracker
68
93
  return Check.new(
69
94
  :ok,
70
95
  "capture",
71
- "SDK integrations enabled: #{config.instrumented_integrations.join(', ')}"
96
+ "SDK integrations enabled: #{config.instrumented_integrations.to_a.join(', ')}"
72
97
  )
73
98
  end
74
99
 
@@ -93,68 +118,110 @@ module LlmCostTracker
93
118
 
94
119
  def table_check
95
120
  return unless active_record_available?
96
- return Check.new(:ok, "llm_api_calls", "table exists") if llm_api_calls_table?
121
+ return Check.new(:ok, "llm_cost_tracker_calls", "table exists") if llm_cost_tracker_calls_table?
97
122
 
98
123
  Check.new(
99
124
  :error,
100
- "llm_api_calls",
125
+ "llm_cost_tracker_calls",
101
126
  "missing; run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
102
127
  )
103
128
  end
104
129
 
105
130
  def column_check
106
- return unless llm_api_calls_table?
131
+ return unless llm_cost_tracker_calls_table?
107
132
 
108
133
  errors = LlmCostTracker::Ledger::Schema::Calls.current_schema_errors
109
- return Check.new(:ok, "llm_api_calls columns", "current") if errors.empty?
110
-
111
- missing = LlmCostTracker::Ledger::Schema::Calls.missing_current_schema_columns
112
- generators = missing.filter_map { |column| COLUMN_GENERATORS[column] }.uniq
113
- message = "current schema required; #{errors.join('; ')}"
114
- message = "#{message}; run #{generators.join(' && ')} && bin/rails db:migrate" if generators.any?
134
+ return Check.new(:ok, "llm_cost_tracker_calls columns", "current") if errors.empty?
115
135
 
116
- Check.new(:error, "llm_api_calls columns", message)
136
+ Check.new(
137
+ :error,
138
+ "llm_cost_tracker_calls columns",
139
+ "schema mismatch: #{errors.join('; ')}; see docs/upgrading.md"
140
+ )
117
141
  end
118
142
 
119
- def period_totals_check
120
- return unless llm_api_calls_table?
143
+ def call_rollups_check
144
+ return unless llm_cost_tracker_calls_table?
145
+ return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
121
146
 
122
- errors = LlmCostTracker::Ledger::Schema::PeriodTotals.current_schema_errors
123
- return Check.new(:ok, "period totals", "llm_cost_tracker_period_totals exists") if errors.empty?
147
+ errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
148
+ return rollups_drift_check if errors.empty?
124
149
 
125
150
  Check.new(
126
151
  :error,
127
- "period totals",
128
- "current schema required; #{errors.join('; ')}; " \
129
- "run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
152
+ "call rollups",
153
+ "schema mismatch: #{errors.join('; ')}; see docs/upgrading.md"
130
154
  )
131
155
  end
132
156
 
157
+ ROLLUPS_DRIFT_TOLERANCE_PERCENT = 1.0
158
+ private_constant :ROLLUPS_DRIFT_TOLERANCE_PERCENT
159
+
160
+ def rollups_drift_check
161
+ drift_window = Time.now.utc.beginning_of_day
162
+ calls_total = LlmCostTracker::Call
163
+ .where(tracked_at: drift_window..)
164
+ .where.not(total_cost: nil)
165
+ .sum(:total_cost)
166
+ rollup_total = LlmCostTracker::CallRollup
167
+ .where(period: "day", period_start: drift_window.to_date)
168
+ .sum(:total_cost)
169
+ return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if calls_total.zero?
170
+
171
+ drift_percent = ((calls_total - rollup_total).abs * 100.0 / calls_total)
172
+ if drift_percent > ROLLUPS_DRIFT_TOLERANCE_PERCENT
173
+ return Check.new(
174
+ :warn, "call rollups",
175
+ "rollups drift detected: today's calls SUM=#{calls_total} vs rollups SUM=#{rollup_total} " \
176
+ "(#{drift_percent.round(2)}% > #{ROLLUPS_DRIFT_TOLERANCE_PERCENT}% threshold). " \
177
+ "Cached budget reads may understate spend until a rebuild."
178
+ )
179
+ end
180
+
181
+ Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists")
182
+ rescue StandardError => e
183
+ Check.new(:warn, "call rollups", "rollups drift check failed: #{e.class}: #{e.message}")
184
+ end
185
+
186
+ def live_rollups_check
187
+ if Probe.table_exists?("llm_cost_tracker_call_rollups")
188
+ Check.new(
189
+ :warn,
190
+ "call rollups",
191
+ "cache_rollups=false but llm_cost_tracker_call_rollups exists. " \
192
+ "Set config.cache_rollups = true to keep budget reads on the rollups fast path or drop the table."
193
+ )
194
+ else
195
+ Check.new(
196
+ :ok,
197
+ "call rollups",
198
+ "cache_rollups=false; budget reads aggregate from llm_cost_tracker_calls directly"
199
+ )
200
+ end
201
+ end
202
+
133
203
  def calls_check
134
- return unless llm_api_calls_table?
204
+ return unless llm_cost_tracker_calls_table?
135
205
 
136
- count = LlmCostTracker::Ledger::Call.count
206
+ snapshot = LlmCostTracker::Call
207
+ .select("COUNT(*) AS tracked_call_count, MAX(tracked_at) AS latest_tracked_at")
208
+ .take
209
+ count = snapshot.tracked_call_count.to_i
137
210
  return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
138
211
 
139
- latest = LlmCostTracker::Ledger::Call.maximum(:tracked_at)&.utc&.iso8601
212
+ latest_at = snapshot.latest_tracked_at
213
+ latest_at = latest_at.to_time if latest_at.respond_to?(:to_time)
214
+ latest = latest_at&.utc&.iso8601
140
215
  Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
141
216
  end
142
217
 
143
218
  def active_record_available?
144
- LlmCostTracker::Ledger::Call.connection
219
+ LlmCostTracker::Call.connection
145
220
  true
146
221
  rescue LoadError, StandardError
147
222
  false
148
223
  end
149
224
 
150
- def llm_api_calls_table?
151
- active_record_available? && table_exists?("llm_api_calls")
152
- end
153
-
154
- def table_exists?(name)
155
- LlmCostTracker::Ledger::Call.connection.data_source_exists?(name)
156
- rescue StandardError
157
- false
158
- end
225
+ def llm_cost_tracker_calls_table? = active_record_available? && Probe.table_exists?("llm_cost_tracker_calls")
159
226
  end
160
227
  end
@@ -3,10 +3,19 @@
3
3
  require "rails"
4
4
  require_relative "../llm_cost_tracker"
5
5
  require_relative "assets"
6
+ require_relative "dashboard_setup_state"
6
7
  require "rack/files"
7
8
 
8
9
  module LlmCostTracker
9
10
  class Engine < ::Rails::Engine
10
11
  isolate_namespace LlmCostTracker
12
+
13
+ initializer "llm_cost_tracker.filter_parameters" do |app|
14
+ app.config.filter_parameters += %i[tag tag_value]
15
+ end
16
+
17
+ initializer "llm_cost_tracker.dashboard_setup_state" do |app|
18
+ app.reloader.to_prepare { LlmCostTracker::DashboardSetupState.reset! }
19
+ end
11
20
  end
12
21
  end
@@ -6,16 +6,12 @@ module LlmCostTracker
6
6
  class InvalidFilterError < Error; end
7
7
 
8
8
  class BudgetExceededError < Error
9
- attr_reader :monthly_total, :daily_total, :call_cost, :total, :budget, :budget_type, :last_event
10
-
11
- def initialize(budget:, last_event: nil, budget_type: nil, total: nil, monthly_total: nil, daily_total: nil,
12
- call_cost: nil)
13
- @monthly_total = monthly_total
14
- @daily_total = daily_total
15
- @call_cost = call_cost
16
- @total = total || monthly_total || daily_total || call_cost
9
+ attr_reader :total, :budget, :budget_type, :last_event
10
+
11
+ def initialize(budget:, budget_type:, total:, last_event: nil)
12
+ @total = total
17
13
  @budget = budget
18
- @budget_type = budget_type || inferred_budget_type
14
+ @budget_type = budget_type
19
15
  @last_event = last_event
20
16
 
21
17
  super(
@@ -23,16 +19,6 @@ module LlmCostTracker
23
19
  "$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
24
20
  )
25
21
  end
26
-
27
- private
28
-
29
- def inferred_budget_type
30
- return :monthly if monthly_total
31
- return :daily if daily_total
32
- return :per_call if call_cost
33
-
34
- :unknown
35
- end
36
22
  end
37
23
 
38
24
  class UnknownPricingError < Error
@@ -13,7 +13,14 @@ module LlmCostTracker
13
13
  :stream,
14
14
  :usage_source,
15
15
  :provider_response_id,
16
- :tracked_at
16
+ :provider_project_id,
17
+ :provider_api_key_id,
18
+ :provider_workspace_id,
19
+ :batch,
20
+ :tracked_at,
21
+ :cost_status,
22
+ :pricing_snapshot,
23
+ :line_items
17
24
  ) do
18
25
  def total_cost
19
26
  cost&.fetch(:total_cost, nil)
@@ -22,8 +29,9 @@ module LlmCostTracker
22
29
  def to_h
23
30
  super.merge(
24
31
  token_usage: token_usage.to_h,
25
- cost: cost&.to_h,
26
- tags: tags ? tags.to_h : {}
32
+ cost: cost && cost.to_h.transform_values { |v| v.is_a?(BigDecimal) ? v.to_f : v },
33
+ tags: tags ? tags.to_h : {},
34
+ line_items: (line_items || []).map(&:to_h)
27
35
  )
28
36
  end
29
37
  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
@@ -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 DurableIngestionGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates the durable ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
14
+ "Required when config.durable_ingestion = true."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "create_llm_cost_tracker_durable_ingestion.rb.erb",
19
+ "db/migrate/create_llm_cost_tracker_durable_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.durable_ingestion = true
29
+ end
30
+
31
+ Without it the durable inbox tables stay unused and Tracker keeps writing
32
+ inline. The doctor check warns about unused durable 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
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
+ require "llm_cost_tracker/billing/components"
6
+ require "llm_cost_tracker/billing/cost_status"
5
7
  require "llm_cost_tracker/pricing"
6
8
  require "llm_cost_tracker/token_usage"
7
9
 
@@ -18,8 +20,8 @@ module LlmCostTracker
18
20
 
19
21
  def create_migration_file
20
22
  migration_template(
21
- "create_llm_api_calls.rb.erb",
22
- "db/migrate/create_llm_api_calls.rb"
23
+ "create_llm_cost_tracker_calls.rb.erb",
24
+ "db/migrate/create_llm_cost_tracker_calls.rb"
23
25
  )
24
26
  end
25
27
 
@@ -33,15 +35,25 @@ module LlmCostTracker
33
35
  def create_prices_file
34
36
  return unless options[:prices]
35
37
 
36
- invoke "llm_cost_tracker:prices"
38
+ require_relative "prices_generator"
39
+ invoke LlmCostTracker::Generators::PricesGenerator
37
40
  end
38
41
 
39
42
  def mount_engine
40
43
  return unless options[:dashboard]
41
44
 
42
45
  add_engine_require
43
- route %(mount LlmCostTracker::Engine => "/llm-costs")
44
- say "Mount /llm-costs behind your app's admin auth before deploying.", :yellow
46
+ say(<<~MSG, :yellow)
47
+ The LLM Cost Tracker dashboard ships without authentication.
48
+ Mount it in config/routes.rb behind your app's admin auth, e.g.:
49
+
50
+ authenticate :admin do
51
+ mount LlmCostTracker::Engine => "/llm-costs"
52
+ end
53
+
54
+ The generator does NOT add a route automatically — leaving the dashboard
55
+ unauthenticated would expose spend, tags, and provider IDs to anyone.
56
+ MSG
45
57
  end
46
58
 
47
59
  private
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "yaml"
4
5
 
5
6
  require_relative "../../pricing/registry"
6
- require_relative "../../pricing/sync/registry_loader"
7
7
  require_relative "../../pricing/sync/registry_writer"
8
8
 
9
9
  module LlmCostTracker
@@ -12,13 +12,9 @@ module LlmCostTracker
12
12
  desc "Creates a local LLM Cost Tracker price snapshot"
13
13
 
14
14
  def create_prices_file
15
- registry = LlmCostTracker::Pricing::Sync::RegistryLoader.new.call(
16
- path: LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH,
17
- seed_path: LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH
18
- )
19
15
  LlmCostTracker::Pricing::Sync::RegistryWriter.new.call(
20
16
  path: File.join(destination_root, "config/llm_cost_tracker_prices.yml"),
21
- registry: registry
17
+ registry: YAML.safe_load_file(LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
22
18
  )
23
19
  end
24
20
  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,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
@@ -0,0 +1,104 @@
1
+ require "llm_cost_tracker/billing/components"
2
+ require "llm_cost_tracker/billing/cost_status"
3
+ require "llm_cost_tracker/ledger/schema/adapter"
4
+
5
+ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
6
+ def change
7
+ create_table :llm_cost_tracker_calls do |t|
8
+ t.string :event_id, null: false
9
+ t.string :provider, null: false
10
+ t.string :model, null: false
11
+ <% LlmCostTracker::TokenUsage.members.each do |column| -%>
12
+ t.integer :<%= column %>, null: false, default: 0
13
+ <% end -%>
14
+ t.decimal :total_cost, precision: 20, scale: 8
15
+ t.integer :latency_ms
16
+ t.boolean :stream, null: false, default: false
17
+ t.string :usage_source
18
+ t.string :provider_response_id
19
+ t.string :provider_project_id
20
+ t.string :provider_api_key_id
21
+ t.string :provider_workspace_id
22
+ t.boolean :batch, null: false, default: false
23
+ t.string :pricing_mode
24
+ t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
25
+ if postgresql?
26
+ t.jsonb :pricing_snapshot
27
+ elsif mysql?
28
+ t.json :pricing_snapshot
29
+ else
30
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
31
+ end
32
+ t.datetime :tracked_at, null: false
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ create_table :llm_cost_tracker_call_line_items do |t|
38
+ t.references :llm_cost_tracker_call,
39
+ null: false,
40
+ index: false,
41
+ foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
42
+ t.integer :position, null: false, default: 0, limit: 2
43
+ t.string :kind, null: false
44
+ t.string :direction, null: false
45
+ t.string :modality, null: false
46
+ t.string :cache_state, null: false, default: "none"
47
+ t.decimal :quantity, precision: 30, scale: 10, null: false
48
+ t.string :unit, null: false
49
+ t.decimal :rate_amount, precision: 20, scale: 8
50
+ t.decimal :rate_quantity, precision: 30, scale: 10, null: false, default: 1
51
+ t.decimal :cost, precision: 20, scale: 8
52
+ t.string :currency, null: false, default: "USD"
53
+ t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
54
+ t.string :pricing_basis
55
+ t.string :price_key
56
+ t.string :price_source
57
+ t.string :price_source_version
58
+ t.string :provider_field
59
+ t.string :provider_item_id
60
+ if postgresql?
61
+ t.jsonb :details, null: false, default: {}
62
+ elsif mysql?
63
+ t.json :details, null: false
64
+ else
65
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
66
+ end
67
+
68
+ t.datetime :created_at, null: false
69
+ end
70
+
71
+ create_table :llm_cost_tracker_call_tags do |t|
72
+ t.references :llm_cost_tracker_call,
73
+ null: false,
74
+ index: false,
75
+ foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
76
+ t.string :key, null: false
77
+ t.text :value, null: false
78
+ end
79
+
80
+ add_index :llm_cost_tracker_calls, :event_id, unique: true
81
+ add_index :llm_cost_tracker_calls, :tracked_at
82
+ add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
83
+ add_index :llm_cost_tracker_calls, [:model, :tracked_at]
84
+ add_index :llm_cost_tracker_calls, :cost_status
85
+ add_index :llm_cost_tracker_calls, :provider_response_id
86
+ add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
87
+ add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
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
93
+ end
94
+
95
+ private
96
+
97
+ def postgresql?
98
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
99
+ end
100
+
101
+ def mysql?
102
+ LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
103
+ end
104
+ end
@@ -0,0 +1,29 @@
1
+ class CreateLlmCostTrackerDurableIngestion < 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
8
+ t.datetime :locked_at
9
+ t.string :locked_by
10
+ t.integer :attempts, null: false, default: 0
11
+ t.text :last_error
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