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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +136 -0
- data/README.md +14 -6
- 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 +21 -11
- 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 +11 -1
- 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 +29 -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/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- 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/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- 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_async_ingestion.rb.erb +29 -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_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -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/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -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/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -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/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- 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
|
|
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
|
+
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
ensure_mutable!
|
|
76
127
|
@redacted_tag_keys = Array(value).map(&:to_s)
|
|
77
128
|
end
|
|
78
129
|
|
|
79
130
|
def instrument(*names)
|
|
80
|
-
|
|
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
|
-
|
|
139
|
+
SCALAR_ATTRIBUTES.each do |name|
|
|
89
140
|
define_method("#{name}=") do |value|
|
|
90
|
-
|
|
141
|
+
ensure_mutable!
|
|
91
142
|
instance_variable_set(:"@#{name}", value)
|
|
92
143
|
end
|
|
93
144
|
end
|
|
94
145
|
|
|
95
|
-
|
|
146
|
+
ENUM_ATTRIBUTES.each do |name, (allowed, default)|
|
|
96
147
|
define_method("#{name}=") do |value|
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
202
|
+
def ensure_mutable!
|
|
148
203
|
return unless finalized?
|
|
149
204
|
|
|
150
205
|
raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
|
|
@@ -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, "
|
|
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
|
-
"
|
|
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, "
|
|
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
|
-
"
|
|
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
|
|
@@ -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
|