llm_cost_tracker 0.8.0 → 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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  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 +5 -7
  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 +10 -0
  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 +24 -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/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -5,6 +5,7 @@ require "active_support/core_ext/object/deep_dup"
5
5
  require "json"
6
6
 
7
7
  require_relative "stream"
8
+ require_relative "../pricing/mode"
8
9
  require_relative "../timing"
9
10
 
10
11
  module LlmCostTracker
@@ -14,7 +15,7 @@ module LlmCostTracker
14
15
 
15
16
  def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
16
17
  provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
17
- metadata: {}, context_tags: nil)
18
+ metadata: {}, context_tags: nil, request: nil)
18
19
  @provider = provider.to_s
19
20
  @model = model
20
21
  @latency_ms = latency_ms
@@ -26,12 +27,14 @@ module LlmCostTracker
26
27
  @pricing_mode = pricing_mode
27
28
  @metadata = (metadata || {}).deep_dup
28
29
  @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
30
+ @request = request
29
31
  @events = []
30
32
  @captured_bytes = 0
31
33
  @overflowed = false
32
34
  @explicit_usage = nil
33
35
  @started_at = LlmCostTracker::Timing.now_monotonic
34
36
  @finished = false
37
+ @recording = false
35
38
  @mutex = Mutex.new
36
39
  end
37
40
 
@@ -88,10 +91,19 @@ module LlmCostTracker
88
91
  end
89
92
 
90
93
  def finish!(errored: false)
91
- snapshot = @mutex.synchronize do
92
- return if @finished
94
+ snapshot = claim_recording_slot
95
+ return if snapshot.nil?
93
96
 
94
- @finished = true
97
+ record_snapshot(snapshot, errored: errored)
98
+ end
99
+
100
+ private
101
+
102
+ def claim_recording_slot
103
+ @mutex.synchronize do
104
+ return nil if @finished || @recording
105
+
106
+ @recording = true
95
107
  pricing_mode = Pricing.normalize_mode(@pricing_mode)
96
108
  {
97
109
  events: @events.dup,
@@ -103,24 +115,52 @@ module LlmCostTracker
103
115
  capture_dimensions: capture_dimensions(pricing_mode),
104
116
  pricing_mode: pricing_mode,
105
117
  metadata: @metadata.deep_dup,
106
- context_tags: @context_tags.deep_dup
118
+ context_tags: @context_tags.deep_dup,
119
+ request: @request
107
120
  }
108
121
  end
122
+ end
123
+
124
+ def record_snapshot(snapshot, errored:)
125
+ save_succeeded = false
126
+ begin
127
+ capture = build_usage_capture(snapshot)
128
+ provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
129
+ capture = capture.with(provider_response_id: provider_response_id)
130
+
131
+ Tracker.record(
132
+ capture: capture,
133
+ latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
134
+ pricing_mode: pricing_mode_for(capture: capture, snapshot: snapshot),
135
+ metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
136
+ context_tags: snapshot[:context_tags]
137
+ ) { |stage| save_succeeded = true if stage == :after_save }
138
+ ensure
139
+ @mutex.synchronize do
140
+ @finished = save_succeeded
141
+ @recording = false
142
+ end
143
+ end
144
+ end
109
145
 
110
- capture = build_usage_capture(snapshot)
111
- provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
112
- capture = capture.with(provider_response_id: provider_response_id)
146
+ HOST_DERIVED_MODE_TOKENS = %i[data_residency].freeze
147
+ STANDARD_LIKE_MODE_TOKENS = %i[standard standard_only auto default].freeze
148
+ private_constant :HOST_DERIVED_MODE_TOKENS, :STANDARD_LIKE_MODE_TOKENS
113
149
 
114
- Tracker.record(
115
- capture: capture,
116
- latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
117
- pricing_mode: snapshot[:pricing_mode],
118
- metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
119
- context_tags: snapshot[:context_tags]
120
- )
150
+ def pricing_mode_for(capture:, snapshot:)
151
+ merge_pricing_modes(capture.pricing_mode, snapshot[:pricing_mode])
121
152
  end
122
153
 
123
- private
154
+ def merge_pricing_modes(provider_mode, request_mode)
155
+ return Pricing.normalize_mode(request_mode) if provider_mode.to_s.strip.empty?
156
+
157
+ provider_tokens = Pricing::Mode.tokenize(provider_mode) - STANDARD_LIKE_MODE_TOKENS
158
+ request_host_tokens = Pricing::Mode.tokenize(request_mode || "") & HOST_DERIVED_MODE_TOKENS
159
+ combined = provider_tokens | request_host_tokens
160
+ return nil if combined.empty?
161
+
162
+ Pricing.normalize_mode(combined.join("_"))
163
+ end
124
164
 
125
165
  def capture_dimensions(pricing_mode)
126
166
  batch = @batch.nil? ? UsageCapture.batch_from_pricing_mode?(pricing_mode).presence : @batch
@@ -140,12 +180,14 @@ module LlmCostTracker
140
180
 
141
181
  def build_usage_capture(snapshot)
142
182
  return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
183
+ return build_unknown_usage(snapshot) if snapshot[:overflowed]
143
184
 
144
185
  capture = Parsers.find_for_provider(@provider)&.parse_stream(
145
186
  response_status: 200,
146
- events: snapshot[:events]
187
+ events: snapshot[:events],
188
+ request_body: request_body_for(snapshot[:request])
147
189
  )
148
- if capture && (capture.usage_source != :unknown || !snapshot[:overflowed])
190
+ if capture
149
191
  model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
150
192
  return capture.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
151
193
  end
@@ -153,6 +195,14 @@ module LlmCostTracker
153
195
  build_unknown_usage(snapshot)
154
196
  end
155
197
 
198
+ def request_body_for(request)
199
+ return nil unless request
200
+
201
+ JSON.generate(request)
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
156
206
  def present_model(value)
157
207
  return nil if value.nil?
158
208
 
@@ -186,8 +236,14 @@ module LlmCostTracker
186
236
  )
187
237
  end
188
238
 
239
+ IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
240
+ private_constant :IGNORED_PAYLOAD_KEYS
241
+
242
+ HEAVY_STRING_BYTES = 8 * 1024
243
+ private_constant :HEAVY_STRING_BYTES
244
+
189
245
  def capture_event(data, type:)
190
- event = { event: type, data: data }
246
+ event = { event: type, data: strip_heavy_payload(data) }
191
247
  size = JSON.generate(event).bytesize
192
248
  if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
193
249
  @events << event.deep_dup
@@ -195,9 +251,26 @@ module LlmCostTracker
195
251
  else
196
252
  @overflowed = true
197
253
  end
198
- rescue JSON::JSONError, TypeError
254
+ rescue JSON::JSONError, TypeError, SystemStackError
199
255
  @overflowed = true
200
256
  end
257
+
258
+ def strip_heavy_payload(value)
259
+ case value
260
+ when Hash
261
+ value.each_with_object({}) do |(key, nested), out|
262
+ next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
263
+
264
+ out[key] = strip_heavy_payload(nested)
265
+ end
266
+ when Array
267
+ value.map { |nested| strip_heavy_payload(nested) }
268
+ when String
269
+ value.bytesize > HEAVY_STRING_BYTES ? "" : value
270
+ else
271
+ value
272
+ end
273
+ end
201
274
  end
202
275
  end
203
276
  end
@@ -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
+ 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
@@ -25,12 +25,17 @@ module LlmCostTracker
25
25
  attr_reader(
26
26
  *SHARED_SCALAR_ATTRIBUTES,
27
27
  :budget_exceeded_behavior,
28
+ :durable_ingestion,
28
29
  :instrumented_integrations,
29
30
  :pricing_overrides,
30
31
  :report_tag_breakdowns,
31
32
  :redacted_tag_keys,
32
33
  :unknown_pricing_behavior,
33
- :openai_compatible_providers
34
+ :openai_compatible_providers,
35
+ :reconciliation_importers,
36
+ :reconciliation_enabled,
37
+ :auto_enable_stream_usage,
38
+ :cache_rollups
34
39
  )
35
40
 
36
41
  def initialize
@@ -51,9 +56,56 @@ module LlmCostTracker
51
56
  @report_tag_breakdowns = []
52
57
  @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
53
58
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
59
+ @reconciliation_importers = {}
60
+ @reconciliation_enabled = false
61
+ @auto_enable_stream_usage = true
62
+ @durable_ingestion = false
63
+ @cache_rollups = false
54
64
  @finalized = false
55
65
  end
56
66
 
67
+ def durable_ingestion=(value)
68
+ ensure_shared_configuration_mutable!
69
+ @durable_ingestion = value
70
+ end
71
+
72
+ def cache_rollups=(value)
73
+ ensure_shared_configuration_mutable!
74
+ @cache_rollups = value
75
+ end
76
+
77
+ def reconciliation_enabled=(value)
78
+ ensure_shared_configuration_mutable!
79
+ @reconciliation_enabled = value
80
+ end
81
+
82
+ def auto_enable_stream_usage=(value)
83
+ ensure_shared_configuration_mutable!
84
+ @auto_enable_stream_usage = value
85
+ end
86
+
87
+ def reconciliation_importers=(importers)
88
+ ensure_shared_configuration_mutable!
89
+ raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
90
+
91
+ @reconciliation_importers = (importers || {}).to_h do |source, importer|
92
+ raise Error, "reconciliation_importers[#{source}] must respond to call" unless importer.respond_to?(:call)
93
+
94
+ [source.to_sym, importer]
95
+ end
96
+ end
97
+
98
+ def register_reconciliation_importer(source, &block)
99
+ ensure_shared_configuration_mutable!
100
+ raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
101
+ raise Error, "register_reconciliation_importer requires a block" unless block
102
+
103
+ @reconciliation_importers[source.to_sym] = block
104
+ end
105
+
106
+ RECONCILIATION_DISABLED_MESSAGE = "reconciliation is disabled; set config.reconciliation_enabled = true first"
107
+ private_constant :RECONCILIATION_DISABLED_MESSAGE
108
+
57
109
  def openai_compatible_providers=(providers)
58
110
  ensure_shared_configuration_mutable!
59
111
  @openai_compatible_providers = normalize_openai_compatible_providers(providers)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ledger/schema/calls"
4
+ require_relative "ledger/schema/call_line_items"
5
+ require_relative "ledger/schema/call_tags"
6
+ require_relative "ledger/schema/call_rollups"
7
+
8
+ module LlmCostTracker
9
+ module DashboardSetupState
10
+ SetupRequired = Data.define(:message, :details)
11
+ DOCS_HINT = "See docs/upgrading.md for the migration path."
12
+ MUTEX = Mutex.new
13
+
14
+ CORE_SCHEMA_CHECKS = [
15
+ [
16
+ LlmCostTracker::Ledger::Schema::Calls,
17
+ "The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
18
+ ],
19
+ [
20
+ LlmCostTracker::Ledger::Schema::CallLineItems,
21
+ "The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
22
+ ],
23
+ [
24
+ LlmCostTracker::Ledger::Schema::CallTags,
25
+ "The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
26
+ ]
27
+ ].freeze
28
+
29
+ OPTIONAL_CALL_ROLLUPS_CHECK = [
30
+ LlmCostTracker::Ledger::Schema::CallRollups,
31
+ "The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
32
+ ].freeze
33
+
34
+ private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
35
+
36
+ class << self
37
+ def current
38
+ return @cached if defined?(@cached)
39
+
40
+ MUTEX.synchronize do
41
+ @cached = compute unless defined?(@cached)
42
+ end
43
+ @cached
44
+ end
45
+
46
+ def reset!
47
+ MUTEX.synchronize do
48
+ remove_instance_variable(:@cached) if defined?(@cached)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def compute
55
+ LlmCostTracker::Logging.debug("DashboardSetupState recomputing")
56
+ return calls_table_missing unless LlmCostTracker::Call.table_exists?
57
+
58
+ core_drift = drift_in(schema_checks_for_current_config)
59
+ return core_drift if core_drift
60
+ return nil unless LlmCostTracker.reconciliation_enabled?
61
+
62
+ reconciliation_drift
63
+ end
64
+
65
+ def schema_checks_for_current_config
66
+ return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
67
+
68
+ CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
69
+ end
70
+
71
+ def drift_in(checks)
72
+ checks.each do |schema, message|
73
+ errors = schema.current_schema_errors
74
+ next if errors.empty?
75
+
76
+ return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
77
+ end
78
+ nil
79
+ end
80
+
81
+ def reconciliation_drift
82
+ LlmCostTracker.const_get(:Reconciliation) # autoload reconciliation + its ledger schemas
83
+ connection = ActiveRecord::Base.connection
84
+ LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
85
+ unless connection.data_source_exists?(table)
86
+ return SetupRequired.new(
87
+ message: "The #{table} table is required when reconciliation is enabled.",
88
+ details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
89
+ )
90
+ end
91
+
92
+ errors = schema.current_schema_errors
93
+ next if errors.empty?
94
+
95
+ message = "The #{table} table does not match the current LLM Cost Tracker schema."
96
+ return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
97
+ end
98
+ nil
99
+ end
100
+
101
+ def calls_table_missing
102
+ SetupRequired.new(
103
+ message: "The llm_cost_tracker_calls table is not available yet.",
104
+ details: nil
105
+ )
106
+ end
107
+ end
108
+ end
109
+ end
@@ -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,6 +11,7 @@ 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.durable?
14
15
 
15
16
  missing = missing_parts
16
17
  if missing.empty?
@@ -43,6 +44,31 @@ module LlmCostTracker
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
+ "durable_ingestion=false; events write directly to the ledger"
54
+ )
55
+ end
56
+
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
+ )
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
@@ -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