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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "check"
4
+ require_relative "probe"
4
5
  require_relative "../ingestion"
5
6
 
6
7
  module LlmCostTracker
@@ -9,66 +10,83 @@ module LlmCostTracker
9
10
  PENDING_AGE_WARNING_SECONDS = 60
10
11
 
11
12
  def call
12
- return unless table_exists?("llm_api_calls")
13
+ return unless Probe.table_exists?("llm_cost_tracker_calls")
14
+ return inline_check unless LlmCostTracker::Ingestion.durable?
13
15
 
14
16
  missing = missing_parts
15
17
  if missing.empty?
16
- quarantined = quarantined_count
18
+ inbox = inbox_snapshot
19
+ quarantined = inbox.try(:quarantined_count).to_i
17
20
  if quarantined.positive?
18
- return Check.new(:warn, "durable ingestion", "#{quarantined} inbox events quarantined after retries")
21
+ return Check.new(:warn, "durable ingestion", "#{quarantined} inbox entries quarantined after retries")
19
22
  end
20
23
 
21
- pending = pending_snapshot
22
- pending_count = pending.try(:pending_count).to_i
23
- oldest_pending_at = pending.try(:oldest_created_at)&.to_time&.utc
24
+ pending_count = inbox.try(:pending_count).to_i
25
+ oldest_pending_at = inbox.try(:oldest_pending_at)&.to_time&.utc
24
26
  pending_age = oldest_pending_at && (Time.now.utc - oldest_pending_at)
25
27
  if pending_count.positive? && pending_age && pending_age >= PENDING_AGE_WARNING_SECONDS
26
28
  return Check.new(
27
29
  :warn,
28
30
  "durable ingestion",
29
- "#{pending_count} inbox events pending; oldest pending age #{pending_age.round}s"
31
+ "#{pending_count} inbox entries pending; oldest pending age #{pending_age.round}s"
30
32
  )
31
33
  end
32
34
 
33
- return Check.new(:ok, "durable ingestion", "inbox and ingestor lease tables available")
35
+ return Check.new(:ok, "durable ingestion", "inbox and ingestion lease tables available")
34
36
  end
35
37
 
36
38
  Check.new(
37
39
  :error,
38
40
  "durable ingestion",
39
- "missing #{missing.join(', ')}; run bin/rails generate llm_cost_tracker:add_ingestion && bin/rails db:migrate"
41
+ "missing #{missing.join(', ')}; see docs/upgrading.md for the recovery steps"
40
42
  )
41
43
  end
42
44
 
43
45
  private
44
46
 
45
- def missing_parts
46
- [
47
- table_exists?("llm_cost_tracker_inbox_events") ? nil : "llm_cost_tracker_inbox_events",
48
- table_exists?("llm_cost_tracker_ingestor_leases") ? nil : "llm_cost_tracker_ingestor_leases"
49
- ].compact
50
- end
47
+ def inline_check
48
+ leftovers = inline_leftover_tables
49
+ if leftovers.empty?
50
+ return Check.new(
51
+ :ok,
52
+ "inline ingestion",
53
+ "durable_ingestion=false; events write directly to the ledger"
54
+ )
55
+ end
51
56
 
52
- def table_exists?(name)
53
- LlmCostTracker::Ledger::Call.connection.data_source_exists?(name)
54
- rescue StandardError
55
- false
57
+ Check.new(
58
+ :warn,
59
+ "inline ingestion",
60
+ "durable_ingestion=false but found unused durable ingestion tables: #{leftovers.join(', ')}. " \
61
+ "Set config.durable_ingestion = true to keep the durable inbox path or drop the tables."
62
+ )
56
63
  end
57
64
 
58
- def quarantined_count
59
- return 0 unless table_exists?("llm_cost_tracker_inbox_events")
65
+ def inline_leftover_tables
66
+ [
67
+ LlmCostTracker::Ingestion::InboxEntry.table_name,
68
+ LlmCostTracker::Ingestion::Lease.table_name
69
+ ].select { |table| Probe.table_exists?(table) }
70
+ end
60
71
 
61
- LlmCostTracker::Ingestion::Event
62
- .where("attempts >= ?", LlmCostTracker::Ingestion::Event::MAX_ATTEMPTS)
63
- .count
64
- rescue StandardError
65
- 0
72
+ def missing_parts
73
+ [
74
+ LlmCostTracker::Ingestion::InboxEntry.table_name,
75
+ LlmCostTracker::Ingestion::Lease.table_name
76
+ ].reject { |table| Probe.table_exists?(table) }
66
77
  end
67
78
 
68
- def pending_snapshot
69
- LlmCostTracker::Ingestion::Event
70
- .where("attempts < ?", LlmCostTracker::Ingestion::Event::MAX_ATTEMPTS)
71
- .select("COUNT(*) AS pending_count, MIN(created_at) AS oldest_created_at")
79
+ def inbox_snapshot
80
+ max_attempts = LlmCostTracker::Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE
81
+ LlmCostTracker::Ingestion::InboxEntry
82
+ .select(
83
+ "COALESCE(SUM(CASE WHEN attempts >= #{max_attempts} " \
84
+ "THEN 1 ELSE 0 END), 0) AS quarantined_count, " \
85
+ "COALESCE(SUM(CASE WHEN attempts < #{max_attempts} " \
86
+ "THEN 1 ELSE 0 END), 0) AS pending_count, " \
87
+ "MIN(CASE WHEN attempts < #{max_attempts} " \
88
+ "THEN created_at ELSE NULL END) AS oldest_pending_at"
89
+ )
72
90
  .take
73
91
  rescue StandardError
74
92
  nil
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "check"
6
+ require_relative "probe"
7
+ require_relative "../ledger/schema/adapter"
8
+
9
+ module LlmCostTracker
10
+ class Doctor
11
+ class InvoiceReconciliationCheck
12
+ def call
13
+ return unless LlmCostTracker.reconciliation_enabled?
14
+ return unless Probe.table_exists?("llm_cost_tracker_provider_invoices")
15
+ return if no_imports?
16
+
17
+ scopes = imported_scopes
18
+ return Check.new(:ok, "invoice reconciliation", "no provider invoices imported yet") if scopes.empty?
19
+
20
+ non_canonical = non_canonical_currency_check
21
+ checks = scopes.map { |scope| check_scope_safely(scope) }
22
+ checks << non_canonical if non_canonical
23
+ checks
24
+ rescue StandardError => e
25
+ Check.new(:error, "invoice reconciliation", e.message)
26
+ end
27
+
28
+ private
29
+
30
+ def no_imports?
31
+ LlmCostTracker::ProviderInvoice.none?
32
+ end
33
+
34
+ def non_canonical_currency_check
35
+ legacy = LlmCostTracker::ProviderInvoice.where("currency <> UPPER(currency)").count
36
+ return nil if legacy.zero?
37
+
38
+ Check.new(
39
+ :warn,
40
+ "invoice reconciliation: currency canonicalisation",
41
+ "#{legacy} provider invoice row(s) stored with non-uppercase currency. Diff queries " \
42
+ "are case-sensitive — run " \
43
+ "`UPDATE llm_cost_tracker_provider_invoices SET currency = UPPER(currency);` to backfill."
44
+ )
45
+ end
46
+
47
+ def threshold
48
+ Reconciliation::DEFAULT_THRESHOLD_PERCENT
49
+ end
50
+
51
+ def imported_scopes
52
+ connection = LlmCostTracker::ProviderInvoice.connection
53
+ provider_expr =
54
+ if Ledger::Schema::Adapter.postgresql?(connection)
55
+ Arel.sql("metadata->>'provider'")
56
+ else
57
+ Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
58
+ end
59
+ LlmCostTracker::ProviderInvoice
60
+ .group(:source, provider_expr, :currency)
61
+ .order(:source, :currency)
62
+ .pluck(:source, provider_expr, :currency)
63
+ .map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
64
+ end
65
+
66
+ def scope_label(scope)
67
+ "#{scope[:source]}/#{scope[:provider]}/#{scope[:currency]}"
68
+ end
69
+
70
+ def check_scope_safely(scope)
71
+ check_scope(scope)
72
+ rescue ArgumentError => e
73
+ Check.new(:warn, "invoice reconciliation: #{scope_label(scope)}", e.message)
74
+ end
75
+
76
+ def check_scope(scope)
77
+ window = latest_window_for(scope)
78
+ return stale_check(scope) if window.nil?
79
+
80
+ diff = run_diff(scope, window)
81
+ return ok_check(scope, window, diff) if diff.aligned?(threshold_percent: threshold)
82
+
83
+ warn_check(scope, window, diff)
84
+ end
85
+
86
+ def scope_relation(scope)
87
+ relation = LlmCostTracker::ProviderInvoice
88
+ .where(source: scope[:source], currency: scope[:currency])
89
+ provider = scope[:provider]
90
+ return relation if provider.nil? || provider.to_s.empty?
91
+
92
+ connection = LlmCostTracker::ProviderInvoice.connection
93
+ if Ledger::Schema::Adapter.postgresql?(connection)
94
+ relation.where("metadata->>'provider' = ?", provider)
95
+ else
96
+ relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
97
+ end
98
+ end
99
+
100
+ def latest_window_for(scope)
101
+ latest = scope_relation(scope)
102
+ .select(:period_start, :period_end)
103
+ .order(period_end: :desc, period_start: :desc)
104
+ .limit(1)
105
+ .first
106
+ return nil unless latest
107
+ return nil if (Time.now.utc.to_date - latest.period_end).to_i > Reconciliation::INVOICE_FRESHNESS_DAYS
108
+
109
+ latest
110
+ end
111
+
112
+ def run_diff(scope, window)
113
+ Reconciliation.diff(
114
+ source: scope[:source],
115
+ provider: scope[:provider],
116
+ currency: scope[:currency],
117
+ period_start: window.period_start,
118
+ period_end: window.period_end
119
+ )
120
+ end
121
+
122
+ def stale_check(scope)
123
+ latest = scope_relation(scope).maximum(:period_end)
124
+ return scope_unreachable_check(scope) if latest.nil?
125
+
126
+ days = (Time.now.utc.to_date - latest).to_i
127
+ Check.new(
128
+ :warn,
129
+ "invoice reconciliation: #{scope_label(scope)}",
130
+ "no invoice imported in #{days} days (threshold #{Reconciliation::INVOICE_FRESHNESS_DAYS} days); " \
131
+ "run reconciliation import"
132
+ )
133
+ end
134
+
135
+ def scope_unreachable_check(scope)
136
+ Check.new(
137
+ :warn,
138
+ "invoice reconciliation: #{scope_label(scope)}",
139
+ "scope grouped invoices but the filter (likely currency case mismatch) matches zero rows; " \
140
+ "the currency-canonicalisation check below points at the backfill SQL"
141
+ )
142
+ end
143
+
144
+ def ok_check(scope, window, diff)
145
+ Check.new(
146
+ :ok,
147
+ "invoice reconciliation: #{scope_label(scope)}",
148
+ "#{window.period_start}..#{window.period_end} aligned " \
149
+ "(local=#{diff.local_total.to_s('F')}, provider=#{diff.provider_total.to_s('F')})"
150
+ )
151
+ end
152
+
153
+ def warn_check(scope, window, diff)
154
+ Check.new(
155
+ :warn,
156
+ "invoice reconciliation: #{scope_label(scope)}",
157
+ "#{window.period_start}..#{window.period_end} drift " \
158
+ "delta=#{diff.delta_amount.to_s('F')} (#{diff.delta_percent}%) " \
159
+ "exceeds #{threshold}% threshold"
160
+ )
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "probe"
5
+ require_relative "../ledger"
6
+
7
+ module LlmCostTracker
8
+ class Doctor
9
+ class LegacyAuditCheck
10
+ WARNING_PERCENT = 10
11
+
12
+ def call
13
+ return unless Probe.table_exists?("llm_cost_tracker_calls")
14
+ return unless LlmCostTracker::Call.column_names.include?("pricing_snapshot")
15
+
16
+ counts = LlmCostTracker::Call
17
+ .select(
18
+ "COUNT(*) AS total_count, " \
19
+ "COALESCE(SUM(CASE WHEN pricing_snapshot IS NULL THEN 1 ELSE 0 END), 0) AS missing_count"
20
+ )
21
+ .take
22
+ total = counts.total_count.to_i
23
+ return if total.zero?
24
+
25
+ missing = counts.missing_count.to_i
26
+ return unless (missing * 100) > (total * WARNING_PERCENT)
27
+
28
+ message = "#{missing}/#{total} tracked calls lack pricing_snapshot; " \
29
+ "stored totals remain stable but applied rates cannot be audited"
30
+ Check.new(:warn, "pricing snapshot audit", message)
31
+ rescue StandardError
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "probe"
5
+ require_relative "../ledger"
6
+
7
+ module LlmCostTracker
8
+ class Doctor
9
+ class LegacyBillingStatusCheck
10
+ def call
11
+ return unless Probe.table_exists?("llm_cost_tracker_calls")
12
+ return unless LlmCostTracker::Call.column_names.include?("cost_status")
13
+
14
+ return unless LlmCostTracker::Call.where(cost_status: nil).exists?
15
+
16
+ Check.new(:warn, "cost status", "legacy rows without cost_status remain; new rows will populate it")
17
+ rescue StandardError
18
+ nil
19
+ end
20
+ end
21
+ end
22
+ end
@@ -8,7 +8,7 @@ module LlmCostTracker
8
8
  class Doctor
9
9
  class PriceCheck
10
10
  STALE_AFTER_DAYS = 30
11
- REFRESH_COMMAND = "run bin/rails llm_cost_tracker:prices:refresh"
11
+ REFRESH_COMMAND = "refresh the source-controlled prices file with bin/rails llm_cost_tracker:prices:refresh"
12
12
 
13
13
  def call
14
14
  path = LlmCostTracker.configuration.prices_file
@@ -48,7 +48,7 @@ module LlmCostTracker
48
48
  Check.new(
49
49
  :warn,
50
50
  "prices",
51
- "using bundled prices updated_at=#{updated_at}; configure prices_file for production"
51
+ "using bundled prices updated_at=#{updated_at}; commit a prices_file for production releases"
52
52
  )
53
53
  end
54
54
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "check"
6
+ require_relative "probe"
7
+
8
+ module LlmCostTracker
9
+ class Doctor
10
+ class PricingSnapshotDriftCheck
11
+ SAMPLE_SIZE = 200
12
+ EPSILON = BigDecimal("0.00000001")
13
+
14
+ def call
15
+ return unless Probe.table_exists?("llm_cost_tracker_calls")
16
+ return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
17
+
18
+ sampled_ids = LlmCostTracker::Call
19
+ .where.not(pricing_snapshot: nil)
20
+ .where(cost_status: %w[complete free])
21
+ .order(id: :desc)
22
+ .limit(SAMPLE_SIZE)
23
+ .pluck(:id)
24
+ return Check.new(:ok, "pricing snapshot drift", "no snapshotted calls to inspect") if sampled_ids.empty?
25
+
26
+ calls_by_id = LlmCostTracker::Call.where(id: sampled_ids).index_by(&:id)
27
+ line_items_by_call = LlmCostTracker::CallLineItem
28
+ .where(llm_cost_tracker_call_id: sampled_ids, unit: "token")
29
+ .group_by(&:llm_cost_tracker_call_id)
30
+
31
+ drifted = sampled_ids.flat_map do |id|
32
+ call = calls_by_id[id]
33
+ rates = rates_for(call.pricing_snapshot)
34
+ next [] if rates.nil? || rates.empty?
35
+
36
+ (line_items_by_call[id] || []).filter_map { |item| drift_message_for(item, rates, call_id: id) }
37
+ end
38
+
39
+ return ok_check(sampled_ids.size) if drifted.empty?
40
+
41
+ Check.new(
42
+ :warn,
43
+ "pricing snapshot drift",
44
+ "line item cost diverges from pricing_snapshot rate in #{drifted.size} cases across " \
45
+ "#{sampled_ids.size} sampled calls: #{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ def ok_check(sample_size)
52
+ Check.new(:ok, "pricing snapshot drift",
53
+ "line item costs match pricing_snapshot rates in #{sample_size} sampled calls")
54
+ end
55
+
56
+ def rates_for(snapshot)
57
+ rates = snapshot.is_a?(Hash) ? (snapshot["rates"] || snapshot[:rates]) : nil
58
+ rates.is_a?(Hash) ? rates : nil
59
+ end
60
+
61
+ def drift_message_for(line_item, rates, call_id:)
62
+ return nil unless line_item.price_key
63
+
64
+ rate = rates[line_item.price_key.to_s] || rates[line_item.price_key.to_sym]
65
+ return nil unless rate.is_a?(Hash)
66
+
67
+ rate_amount = decimal(rate["amount"] || rate[:amount])
68
+ rate_quantity = decimal(rate["quantity"] || rate[:quantity])
69
+ return nil if rate_amount.nil? || rate_quantity.nil? || rate_quantity.zero?
70
+
71
+ expected = (decimal(line_item.quantity) * rate_amount) / rate_quantity
72
+ actual = decimal(line_item.cost) || BigDecimal("0")
73
+ return nil if (expected - actual).abs <= EPSILON
74
+
75
+ "##{call_id}.#{line_item.price_key}: expected=#{expected.round(8).to_s('F')} stored=#{actual.to_s('F')}"
76
+ end
77
+
78
+ def decimal(value)
79
+ return nil if value.nil?
80
+
81
+ BigDecimal(value.to_s)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ledger"
4
+
5
+ module LlmCostTracker
6
+ class Doctor
7
+ module Probe
8
+ module_function
9
+
10
+ def table_exists?(name)
11
+ LlmCostTracker::Call.connection.data_source_exists?(name)
12
+ rescue StandardError
13
+ false
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "probe"
5
+ require_relative "../ledger"
6
+
7
+ module LlmCostTracker
8
+ class Doctor
9
+ class SchemaCheck
10
+ def initialize(name:, schema:, table:, optional: false, install_command: "llm_cost_tracker:install")
11
+ @name = name
12
+ @schema = schema
13
+ @table = table
14
+ @optional = optional
15
+ @install_command = install_command
16
+ end
17
+
18
+ def call
19
+ return unless Probe.table_exists?("llm_cost_tracker_calls")
20
+ return if @optional && !Probe.table_exists?(@table)
21
+
22
+ errors = @schema.current_schema_errors
23
+ return Check.new(:ok, @name, "#{@table} exists") if errors.empty?
24
+
25
+ Check.new(
26
+ :error,
27
+ @name,
28
+ "current schema required; #{errors.join('; ')}; " \
29
+ "run bin/rails generate #{@install_command} && bin/rails db:migrate"
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end