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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +12 -5
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
- data/lib/llm_cost_tracker/budget.rb +4 -2
- data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +53 -1
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -3
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/event.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
- data/lib/llm_cost_tracker/ingestion.rb +48 -10
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
- data/lib/llm_cost_tracker/integrations/base.rb +22 -5
- data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
- data/lib/llm_cost_tracker/integrations.rb +19 -1
- data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
- data/lib/llm_cost_tracker/ledger/store.rb +14 -14
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
- data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
- data/lib/llm_cost_tracker/parsers/base.rb +5 -1
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
- data/lib/llm_cost_tracker/prices.json +110 -19
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
- data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
- data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
- data/lib/llm_cost_tracker/pricing.rb +47 -19
- data/lib/llm_cost_tracker/railtie.rb +6 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/retention.rb +15 -2
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
- data/lib/llm_cost_tracker/token_usage.rb +10 -2
- data/lib/llm_cost_tracker/tracker.rb +45 -18
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +9 -0
- data/lib/tasks/llm_cost_tracker.rake +25 -2
- 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 =
|
|
92
|
-
|
|
94
|
+
snapshot = claim_recording_slot
|
|
95
|
+
return if snapshot.nil?
|
|
93
96
|
|
|
94
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
16
|
-
@
|
|
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
|
-
|
|
125
|
+
@attempted_ref[0] = true
|
|
126
|
+
next false if @finished_ref[0]
|
|
124
127
|
|
|
125
|
-
@
|
|
128
|
+
@finished_ref[0] = true
|
|
126
129
|
true
|
|
127
130
|
end
|
|
128
131
|
return unless should_finish && @active.call
|
|
129
132
|
|
|
130
|
-
|
|
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
|
|
29
|
+
"run bin/rails generate #{@install_command} && bin/rails db:migrate"
|
|
27
30
|
)
|
|
28
31
|
end
|
|
29
32
|
end
|