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
@@ -12,8 +12,9 @@ module LlmCostTracker
12
12
  @stream = stream
13
13
  @collector = collector
14
14
  @active = active
15
- @finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
16
- @finished = false
15
+ @finish = finish || proc { |errored| collector.finish!(errored: errored) }
16
+ @finished_ref = [false]
17
+ @attempted_ref = [false]
17
18
  @capture_failed = false
18
19
  @mutex = Mutex.new
19
20
  end
@@ -33,6 +34,7 @@ module LlmCostTracker
33
34
  end
34
35
  wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
35
36
 
37
+ register_orphan_finalizer
36
38
  @stream
37
39
  rescue StandardError => e
38
40
  Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
@@ -120,14 +122,47 @@ module LlmCostTracker
120
122
 
121
123
  def finish!(errored:)
122
124
  should_finish = @mutex.synchronize do
123
- next false if @finished
125
+ @attempted_ref[0] = true
126
+ next false if @finished_ref[0]
124
127
 
125
- @finished = true
128
+ @finished_ref[0] = true
126
129
  true
127
130
  end
128
131
  return unless should_finish && @active.call
129
132
 
130
- @finish.call(errored: errored)
133
+ begin
134
+ @finish.call(errored)
135
+ rescue StandardError
136
+ @mutex.synchronize { @finished_ref[0] = false }
137
+ raise
138
+ end
139
+ end
140
+
141
+ def register_orphan_finalizer
142
+ finished_ref = @finished_ref
143
+ attempted_ref = @attempted_ref
144
+ finish_proc = @finish
145
+ active_proc = @active
146
+ mutex = @mutex
147
+ finalizer = lambda do |_object_id|
148
+ should_finish = mutex.synchronize do
149
+ next false if finished_ref[0] || attempted_ref[0]
150
+
151
+ finished_ref[0] = true
152
+ attempted_ref[0] = true
153
+ true
154
+ end
155
+ next unless should_finish && active_proc.call
156
+
157
+ begin
158
+ Rails.application.executor.wrap { finish_proc.call(false) }
159
+ rescue StandardError
160
+ nil
161
+ end
162
+ end
163
+ ObjectSpace.define_finalizer(@stream, finalizer)
164
+ rescue TypeError, ArgumentError
165
+ nil
131
166
  end
132
167
  end
133
168
  end
@@ -14,23 +14,31 @@ module LlmCostTracker
14
14
 
15
15
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
16
16
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
17
- SHARED_SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
18
- log_level prices_file max_tag_count max_tag_value_bytesize].freeze
19
- SHARED_ENUM_ATTRIBUTES = {
17
+ INGESTION_MODES = %i[inline async].freeze
18
+ SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
19
+ log_level prices_file max_tag_count max_tag_value_bytesize
20
+ ingestion_pool_size].freeze
21
+ ENUM_ATTRIBUTES = {
20
22
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
21
- unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
23
+ unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn],
24
+ ingestion: [INGESTION_MODES, :inline]
22
25
  }.freeze
23
26
  DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
24
27
 
25
28
  attr_reader(
26
- *SHARED_SCALAR_ATTRIBUTES,
29
+ *SCALAR_ATTRIBUTES,
27
30
  :budget_exceeded_behavior,
31
+ :ingestion,
28
32
  :instrumented_integrations,
29
33
  :pricing_overrides,
30
34
  :report_tag_breakdowns,
31
35
  :redacted_tag_keys,
32
36
  :unknown_pricing_behavior,
33
- :openai_compatible_providers
37
+ :openai_compatible_providers,
38
+ :reconciliation_importers,
39
+ :reconciliation_enabled,
40
+ :auto_enable_stream_usage,
41
+ :cache_rollups
34
42
  )
35
43
 
36
44
  def initialize
@@ -46,38 +54,81 @@ module LlmCostTracker
46
54
  @prices_file = nil
47
55
  @max_tag_count = 50
48
56
  @max_tag_value_bytesize = 1024
57
+ @ingestion_pool_size = nil
49
58
  self.pricing_overrides = {}
50
59
  @instrumented_integrations = Set.new
51
60
  @report_tag_breakdowns = []
52
61
  @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
53
62
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
63
+ @reconciliation_importers = {}
64
+ @reconciliation_enabled = false
65
+ @auto_enable_stream_usage = true
66
+ self.ingestion = :inline
67
+ @cache_rollups = false
54
68
  @finalized = false
55
69
  end
56
70
 
71
+ def cache_rollups=(value)
72
+ ensure_mutable!
73
+ @cache_rollups = value
74
+ end
75
+
76
+ def reconciliation_enabled=(value)
77
+ ensure_mutable!
78
+ @reconciliation_enabled = value
79
+ end
80
+
81
+ def auto_enable_stream_usage=(value)
82
+ ensure_mutable!
83
+ @auto_enable_stream_usage = value
84
+ end
85
+
86
+ def reconciliation_importers=(importers)
87
+ ensure_mutable!
88
+ raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
89
+
90
+ @reconciliation_importers = (importers || {}).to_h do |source, importer|
91
+ raise Error, "reconciliation_importers[#{source}] must respond to call" unless importer.respond_to?(:call)
92
+
93
+ [source.to_sym, importer]
94
+ end
95
+ end
96
+
97
+ def register_reconciliation_importer(source, &block)
98
+ ensure_mutable!
99
+ raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
100
+ raise Error, "register_reconciliation_importer requires a block" unless block
101
+
102
+ @reconciliation_importers[source.to_sym] = block
103
+ end
104
+
105
+ RECONCILIATION_DISABLED_MESSAGE = "reconciliation is disabled; set config.reconciliation_enabled = true first"
106
+ private_constant :RECONCILIATION_DISABLED_MESSAGE
107
+
57
108
  def openai_compatible_providers=(providers)
58
- ensure_shared_configuration_mutable!
109
+ ensure_mutable!
59
110
  @openai_compatible_providers = normalize_openai_compatible_providers(providers)
60
111
  end
61
112
 
62
113
  def pricing_overrides=(value)
63
- ensure_shared_configuration_mutable!
114
+ ensure_mutable!
64
115
  @pricing_overrides = Pricing::Registry.normalize_price_table(value || {})
65
116
  rescue ArgumentError => e
66
117
  raise Error, "invalid pricing_overrides: #{e.message}"
67
118
  end
68
119
 
69
120
  def report_tag_breakdowns=(value)
70
- ensure_shared_configuration_mutable!
121
+ ensure_mutable!
71
122
  @report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
72
123
  end
73
124
 
74
125
  def redacted_tag_keys=(value)
75
- ensure_shared_configuration_mutable!
126
+ ensure_mutable!
76
127
  @redacted_tag_keys = Array(value).map(&:to_s)
77
128
  end
78
129
 
79
130
  def instrument(*names)
80
- ensure_shared_configuration_mutable!
131
+ ensure_mutable!
81
132
  @instrumented_integrations.merge(normalize_instrumentation_names(names))
82
133
  end
83
134
 
@@ -85,16 +136,16 @@ module LlmCostTracker
85
136
  @instrumented_integrations.include?(name)
86
137
  end
87
138
 
88
- SHARED_SCALAR_ATTRIBUTES.each do |name|
139
+ SCALAR_ATTRIBUTES.each do |name|
89
140
  define_method("#{name}=") do |value|
90
- ensure_shared_configuration_mutable!
141
+ ensure_mutable!
91
142
  instance_variable_set(:"@#{name}", value)
92
143
  end
93
144
  end
94
145
 
95
- SHARED_ENUM_ATTRIBUTES.each do |name, (allowed, default)|
146
+ ENUM_ATTRIBUTES.each do |name, (allowed, default)|
96
147
  define_method("#{name}=") do |value|
97
- ensure_shared_configuration_mutable!
148
+ ensure_mutable!
98
149
  instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default: default))
99
150
  end
100
151
  end
@@ -109,7 +160,11 @@ module LlmCostTracker
109
160
  normalize_openai_compatible_providers(@openai_compatible_providers)
110
161
  )
111
162
  @finalized = true
112
- self
163
+ end
164
+
165
+ def normalized_redacted_tag_keys
166
+ @normalized_redacted_tag_keys ||=
167
+ Array(@redacted_tag_keys).map { |key| Tags::Sanitizer.normalized_key(key) }.freeze
113
168
  end
114
169
 
115
170
  def finalized?
@@ -144,7 +199,7 @@ module LlmCostTracker
144
199
  names
145
200
  end
146
201
 
147
- def ensure_shared_configuration_mutable!
202
+ def ensure_mutable!
148
203
  return unless finalized?
149
204
 
150
205
  raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
@@ -47,7 +47,7 @@ module LlmCostTracker
47
47
  end
48
48
 
49
49
  LlmCostTracker::Integrations.checks.map do |check|
50
- Check.new(check.status, "sdk integration #{check.name}", check.message)
50
+ check.with(name: "sdk integration #{check.name}")
51
51
  end
52
52
  end
53
53
 
@@ -4,6 +4,7 @@ require "bigdecimal"
4
4
 
5
5
  require_relative "check"
6
6
  require_relative "probe"
7
+ require_relative "../ledger/rollups"
7
8
 
8
9
  module LlmCostTracker
9
10
  class Doctor
@@ -25,6 +26,7 @@ module LlmCostTracker
25
26
 
26
27
  line_item_totals = LlmCostTracker::CallLineItem
27
28
  .where(llm_cost_tracker_call_id: sampled.map(&:first))
29
+ .where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
28
30
  .group(:llm_cost_tracker_call_id)
29
31
  .sum(:cost)
30
32
 
@@ -11,13 +11,14 @@ module LlmCostTracker
11
11
 
12
12
  def call
13
13
  return unless Probe.table_exists?("llm_cost_tracker_calls")
14
+ return inline_check unless LlmCostTracker::Ingestion.async?
14
15
 
15
16
  missing = missing_parts
16
17
  if missing.empty?
17
18
  inbox = inbox_snapshot
18
19
  quarantined = inbox.try(:quarantined_count).to_i
19
20
  if quarantined.positive?
20
- return Check.new(:warn, "durable ingestion", "#{quarantined} inbox entries quarantined after retries")
21
+ return Check.new(:warn, "async ingestion", "#{quarantined} inbox entries quarantined after retries")
21
22
  end
22
23
 
23
24
  pending_count = inbox.try(:pending_count).to_i
@@ -26,23 +27,48 @@ module LlmCostTracker
26
27
  if pending_count.positive? && pending_age && pending_age >= PENDING_AGE_WARNING_SECONDS
27
28
  return Check.new(
28
29
  :warn,
29
- "durable ingestion",
30
+ "async ingestion",
30
31
  "#{pending_count} inbox entries pending; oldest pending age #{pending_age.round}s"
31
32
  )
32
33
  end
33
34
 
34
- return Check.new(:ok, "durable ingestion", "inbox and ingestion lease tables available")
35
+ return Check.new(:ok, "async ingestion", "inbox and ingestion lease tables available")
35
36
  end
36
37
 
37
38
  Check.new(
38
39
  :error,
39
- "durable ingestion",
40
+ "async ingestion",
40
41
  "missing #{missing.join(', ')}; see docs/upgrading.md for the recovery steps"
41
42
  )
42
43
  end
43
44
 
44
45
  private
45
46
 
47
+ def inline_check
48
+ leftovers = inline_leftover_tables
49
+ if leftovers.empty?
50
+ return Check.new(
51
+ :ok,
52
+ "inline ingestion",
53
+ "config.ingestion = :inline; events write directly to the ledger"
54
+ )
55
+ end
56
+
57
+ Check.new(
58
+ :warn,
59
+ "inline ingestion",
60
+ "config.ingestion = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
61
+ "Set config.ingestion = :async to keep the inbox path or drop the tables."
62
+ )
63
+ end
64
+
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
71
+
46
72
  def missing_parts
47
73
  [
48
74
  LlmCostTracker::Ingestion::InboxEntry.table_name,
@@ -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
@@ -28,8 +28,6 @@ module LlmCostTracker
28
28
  message = "#{missing}/#{total} tracked calls lack pricing_snapshot; " \
29
29
  "stored totals remain stable but applied rates cannot be audited"
30
30
  Check.new(:warn, "pricing snapshot audit", message)
31
- rescue StandardError
32
- nil
33
31
  end
34
32
  end
35
33
  end
@@ -14,8 +14,6 @@ module LlmCostTracker
14
14
  return unless LlmCostTracker::Call.where(cost_status: nil).exists?
15
15
 
16
16
  Check.new(:warn, "cost status", "legacy rows without cost_status remain; new rows will populate it")
17
- rescue StandardError
18
- nil
19
17
  end
20
18
  end
21
19
  end
@@ -7,14 +7,17 @@ require_relative "../ledger"
7
7
  module LlmCostTracker
8
8
  class Doctor
9
9
  class SchemaCheck
10
- def initialize(name:, schema:, table:)
10
+ def initialize(name:, schema:, table:, optional: false, install_command: "llm_cost_tracker:install")
11
11
  @name = name
12
12
  @schema = schema
13
13
  @table = table
14
+ @optional = optional
15
+ @install_command = install_command
14
16
  end
15
17
 
16
18
  def call
17
19
  return unless Probe.table_exists?("llm_cost_tracker_calls")
20
+ return if @optional && !Probe.table_exists?(@table)
18
21
 
19
22
  errors = @schema.current_schema_errors
20
23
  return Check.new(:ok, @name, "#{@table} exists") if errors.empty?
@@ -23,7 +26,7 @@ module LlmCostTracker
23
26
  :error,
24
27
  @name,
25
28
  "current schema required; #{errors.join('; ')}; " \
26
- "run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
29
+ "run bin/rails generate #{@install_command} && bin/rails db:migrate"
27
30
  )
28
31
  end
29
32
  end