llm_cost_tracker 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- 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 +18 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -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 +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- 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 +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- 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 +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- 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 +104 -0
- 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/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- 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 +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- 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 +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "check"
|
|
4
|
+
require_relative "probe"
|
|
4
5
|
require_relative "../ingestion"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
@@ -9,66 +10,83 @@ module LlmCostTracker
|
|
|
9
10
|
PENDING_AGE_WARNING_SECONDS = 60
|
|
10
11
|
|
|
11
12
|
def call
|
|
12
|
-
return unless table_exists?("
|
|
13
|
+
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
14
|
+
return inline_check unless LlmCostTracker::Ingestion.durable?
|
|
13
15
|
|
|
14
16
|
missing = missing_parts
|
|
15
17
|
if missing.empty?
|
|
16
|
-
|
|
18
|
+
inbox = inbox_snapshot
|
|
19
|
+
quarantined = inbox.try(:quarantined_count).to_i
|
|
17
20
|
if quarantined.positive?
|
|
18
|
-
return Check.new(:warn, "durable ingestion", "#{quarantined} inbox
|
|
21
|
+
return Check.new(:warn, "durable ingestion", "#{quarantined} inbox entries quarantined after retries")
|
|
19
22
|
end
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
oldest_pending_at = pending.try(:oldest_created_at)&.to_time&.utc
|
|
24
|
+
pending_count = inbox.try(:pending_count).to_i
|
|
25
|
+
oldest_pending_at = inbox.try(:oldest_pending_at)&.to_time&.utc
|
|
24
26
|
pending_age = oldest_pending_at && (Time.now.utc - oldest_pending_at)
|
|
25
27
|
if pending_count.positive? && pending_age && pending_age >= PENDING_AGE_WARNING_SECONDS
|
|
26
28
|
return Check.new(
|
|
27
29
|
:warn,
|
|
28
30
|
"durable ingestion",
|
|
29
|
-
"#{pending_count} inbox
|
|
31
|
+
"#{pending_count} inbox entries pending; oldest pending age #{pending_age.round}s"
|
|
30
32
|
)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
return Check.new(:ok, "durable ingestion", "inbox and
|
|
35
|
+
return Check.new(:ok, "durable ingestion", "inbox and ingestion lease tables available")
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
Check.new(
|
|
37
39
|
:error,
|
|
38
40
|
"durable ingestion",
|
|
39
|
-
"missing #{missing.join(', ')};
|
|
41
|
+
"missing #{missing.join(', ')}; see docs/upgrading.md for the recovery steps"
|
|
40
42
|
)
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
private
|
|
44
46
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
def inline_check
|
|
48
|
+
leftovers = inline_leftover_tables
|
|
49
|
+
if leftovers.empty?
|
|
50
|
+
return Check.new(
|
|
51
|
+
:ok,
|
|
52
|
+
"inline ingestion",
|
|
53
|
+
"durable_ingestion=false; events write directly to the ledger"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
Check.new(
|
|
58
|
+
:warn,
|
|
59
|
+
"inline ingestion",
|
|
60
|
+
"durable_ingestion=false but found unused durable ingestion tables: #{leftovers.join(', ')}. " \
|
|
61
|
+
"Set config.durable_ingestion = true to keep the durable inbox path or drop the tables."
|
|
62
|
+
)
|
|
56
63
|
end
|
|
57
64
|
|
|
58
|
-
def
|
|
59
|
-
|
|
65
|
+
def inline_leftover_tables
|
|
66
|
+
[
|
|
67
|
+
LlmCostTracker::Ingestion::InboxEntry.table_name,
|
|
68
|
+
LlmCostTracker::Ingestion::Lease.table_name
|
|
69
|
+
].select { |table| Probe.table_exists?(table) }
|
|
70
|
+
end
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
def missing_parts
|
|
73
|
+
[
|
|
74
|
+
LlmCostTracker::Ingestion::InboxEntry.table_name,
|
|
75
|
+
LlmCostTracker::Ingestion::Lease.table_name
|
|
76
|
+
].reject { |table| Probe.table_exists?(table) }
|
|
66
77
|
end
|
|
67
78
|
|
|
68
|
-
def
|
|
69
|
-
LlmCostTracker::Ingestion::
|
|
70
|
-
|
|
71
|
-
.select(
|
|
79
|
+
def inbox_snapshot
|
|
80
|
+
max_attempts = LlmCostTracker::Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE
|
|
81
|
+
LlmCostTracker::Ingestion::InboxEntry
|
|
82
|
+
.select(
|
|
83
|
+
"COALESCE(SUM(CASE WHEN attempts >= #{max_attempts} " \
|
|
84
|
+
"THEN 1 ELSE 0 END), 0) AS quarantined_count, " \
|
|
85
|
+
"COALESCE(SUM(CASE WHEN attempts < #{max_attempts} " \
|
|
86
|
+
"THEN 1 ELSE 0 END), 0) AS pending_count, " \
|
|
87
|
+
"MIN(CASE WHEN attempts < #{max_attempts} " \
|
|
88
|
+
"THEN created_at ELSE NULL END) AS oldest_pending_at"
|
|
89
|
+
)
|
|
72
90
|
.take
|
|
73
91
|
rescue StandardError
|
|
74
92
|
nil
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "check"
|
|
6
|
+
require_relative "probe"
|
|
7
|
+
require_relative "../ledger/schema/adapter"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
class Doctor
|
|
11
|
+
class InvoiceReconciliationCheck
|
|
12
|
+
def call
|
|
13
|
+
return unless LlmCostTracker.reconciliation_enabled?
|
|
14
|
+
return unless Probe.table_exists?("llm_cost_tracker_provider_invoices")
|
|
15
|
+
return if no_imports?
|
|
16
|
+
|
|
17
|
+
scopes = imported_scopes
|
|
18
|
+
return Check.new(:ok, "invoice reconciliation", "no provider invoices imported yet") if scopes.empty?
|
|
19
|
+
|
|
20
|
+
non_canonical = non_canonical_currency_check
|
|
21
|
+
checks = scopes.map { |scope| check_scope_safely(scope) }
|
|
22
|
+
checks << non_canonical if non_canonical
|
|
23
|
+
checks
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Check.new(:error, "invoice reconciliation", e.message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def no_imports?
|
|
31
|
+
LlmCostTracker::ProviderInvoice.none?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def non_canonical_currency_check
|
|
35
|
+
legacy = LlmCostTracker::ProviderInvoice.where("currency <> UPPER(currency)").count
|
|
36
|
+
return nil if legacy.zero?
|
|
37
|
+
|
|
38
|
+
Check.new(
|
|
39
|
+
:warn,
|
|
40
|
+
"invoice reconciliation: currency canonicalisation",
|
|
41
|
+
"#{legacy} provider invoice row(s) stored with non-uppercase currency. Diff queries " \
|
|
42
|
+
"are case-sensitive — run " \
|
|
43
|
+
"`UPDATE llm_cost_tracker_provider_invoices SET currency = UPPER(currency);` to backfill."
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def threshold
|
|
48
|
+
Reconciliation::DEFAULT_THRESHOLD_PERCENT
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def imported_scopes
|
|
52
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
53
|
+
provider_expr =
|
|
54
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
55
|
+
Arel.sql("metadata->>'provider'")
|
|
56
|
+
else
|
|
57
|
+
Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
|
|
58
|
+
end
|
|
59
|
+
LlmCostTracker::ProviderInvoice
|
|
60
|
+
.group(:source, provider_expr, :currency)
|
|
61
|
+
.order(:source, :currency)
|
|
62
|
+
.pluck(:source, provider_expr, :currency)
|
|
63
|
+
.map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scope_label(scope)
|
|
67
|
+
"#{scope[:source]}/#{scope[:provider]}/#{scope[:currency]}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_scope_safely(scope)
|
|
71
|
+
check_scope(scope)
|
|
72
|
+
rescue ArgumentError => e
|
|
73
|
+
Check.new(:warn, "invoice reconciliation: #{scope_label(scope)}", e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def check_scope(scope)
|
|
77
|
+
window = latest_window_for(scope)
|
|
78
|
+
return stale_check(scope) if window.nil?
|
|
79
|
+
|
|
80
|
+
diff = run_diff(scope, window)
|
|
81
|
+
return ok_check(scope, window, diff) if diff.aligned?(threshold_percent: threshold)
|
|
82
|
+
|
|
83
|
+
warn_check(scope, window, diff)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def scope_relation(scope)
|
|
87
|
+
relation = LlmCostTracker::ProviderInvoice
|
|
88
|
+
.where(source: scope[:source], currency: scope[:currency])
|
|
89
|
+
provider = scope[:provider]
|
|
90
|
+
return relation if provider.nil? || provider.to_s.empty?
|
|
91
|
+
|
|
92
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
93
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
94
|
+
relation.where("metadata->>'provider' = ?", provider)
|
|
95
|
+
else
|
|
96
|
+
relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def latest_window_for(scope)
|
|
101
|
+
latest = scope_relation(scope)
|
|
102
|
+
.select(:period_start, :period_end)
|
|
103
|
+
.order(period_end: :desc, period_start: :desc)
|
|
104
|
+
.limit(1)
|
|
105
|
+
.first
|
|
106
|
+
return nil unless latest
|
|
107
|
+
return nil if (Time.now.utc.to_date - latest.period_end).to_i > Reconciliation::INVOICE_FRESHNESS_DAYS
|
|
108
|
+
|
|
109
|
+
latest
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run_diff(scope, window)
|
|
113
|
+
Reconciliation.diff(
|
|
114
|
+
source: scope[:source],
|
|
115
|
+
provider: scope[:provider],
|
|
116
|
+
currency: scope[:currency],
|
|
117
|
+
period_start: window.period_start,
|
|
118
|
+
period_end: window.period_end
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def stale_check(scope)
|
|
123
|
+
latest = scope_relation(scope).maximum(:period_end)
|
|
124
|
+
return scope_unreachable_check(scope) if latest.nil?
|
|
125
|
+
|
|
126
|
+
days = (Time.now.utc.to_date - latest).to_i
|
|
127
|
+
Check.new(
|
|
128
|
+
:warn,
|
|
129
|
+
"invoice reconciliation: #{scope_label(scope)}",
|
|
130
|
+
"no invoice imported in #{days} days (threshold #{Reconciliation::INVOICE_FRESHNESS_DAYS} days); " \
|
|
131
|
+
"run reconciliation import"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def scope_unreachable_check(scope)
|
|
136
|
+
Check.new(
|
|
137
|
+
:warn,
|
|
138
|
+
"invoice reconciliation: #{scope_label(scope)}",
|
|
139
|
+
"scope grouped invoices but the filter (likely currency case mismatch) matches zero rows; " \
|
|
140
|
+
"the currency-canonicalisation check below points at the backfill SQL"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def ok_check(scope, window, diff)
|
|
145
|
+
Check.new(
|
|
146
|
+
:ok,
|
|
147
|
+
"invoice reconciliation: #{scope_label(scope)}",
|
|
148
|
+
"#{window.period_start}..#{window.period_end} aligned " \
|
|
149
|
+
"(local=#{diff.local_total.to_s('F')}, provider=#{diff.provider_total.to_s('F')})"
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def warn_check(scope, window, diff)
|
|
154
|
+
Check.new(
|
|
155
|
+
:warn,
|
|
156
|
+
"invoice reconciliation: #{scope_label(scope)}",
|
|
157
|
+
"#{window.period_start}..#{window.period_end} drift " \
|
|
158
|
+
"delta=#{diff.delta_amount.to_s('F')} (#{diff.delta_percent}%) " \
|
|
159
|
+
"exceeds #{threshold}% threshold"
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check"
|
|
4
|
+
require_relative "probe"
|
|
5
|
+
require_relative "../ledger"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
class Doctor
|
|
9
|
+
class LegacyAuditCheck
|
|
10
|
+
WARNING_PERCENT = 10
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
14
|
+
return unless LlmCostTracker::Call.column_names.include?("pricing_snapshot")
|
|
15
|
+
|
|
16
|
+
counts = LlmCostTracker::Call
|
|
17
|
+
.select(
|
|
18
|
+
"COUNT(*) AS total_count, " \
|
|
19
|
+
"COALESCE(SUM(CASE WHEN pricing_snapshot IS NULL THEN 1 ELSE 0 END), 0) AS missing_count"
|
|
20
|
+
)
|
|
21
|
+
.take
|
|
22
|
+
total = counts.total_count.to_i
|
|
23
|
+
return if total.zero?
|
|
24
|
+
|
|
25
|
+
missing = counts.missing_count.to_i
|
|
26
|
+
return unless (missing * 100) > (total * WARNING_PERCENT)
|
|
27
|
+
|
|
28
|
+
message = "#{missing}/#{total} tracked calls lack pricing_snapshot; " \
|
|
29
|
+
"stored totals remain stable but applied rates cannot be audited"
|
|
30
|
+
Check.new(:warn, "pricing snapshot audit", message)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check"
|
|
4
|
+
require_relative "probe"
|
|
5
|
+
require_relative "../ledger"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
class Doctor
|
|
9
|
+
class LegacyBillingStatusCheck
|
|
10
|
+
def call
|
|
11
|
+
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
12
|
+
return unless LlmCostTracker::Call.column_names.include?("cost_status")
|
|
13
|
+
|
|
14
|
+
return unless LlmCostTracker::Call.where(cost_status: nil).exists?
|
|
15
|
+
|
|
16
|
+
Check.new(:warn, "cost status", "legacy rows without cost_status remain; new rows will populate it")
|
|
17
|
+
rescue StandardError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -8,7 +8,7 @@ module LlmCostTracker
|
|
|
8
8
|
class Doctor
|
|
9
9
|
class PriceCheck
|
|
10
10
|
STALE_AFTER_DAYS = 30
|
|
11
|
-
REFRESH_COMMAND = "
|
|
11
|
+
REFRESH_COMMAND = "refresh the source-controlled prices file with bin/rails llm_cost_tracker:prices:refresh"
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
14
|
path = LlmCostTracker.configuration.prices_file
|
|
@@ -48,7 +48,7 @@ module LlmCostTracker
|
|
|
48
48
|
Check.new(
|
|
49
49
|
:warn,
|
|
50
50
|
"prices",
|
|
51
|
-
"using bundled prices updated_at=#{updated_at};
|
|
51
|
+
"using bundled prices updated_at=#{updated_at}; commit a prices_file for production releases"
|
|
52
52
|
)
|
|
53
53
|
end
|
|
54
54
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "check"
|
|
6
|
+
require_relative "probe"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
class Doctor
|
|
10
|
+
class PricingSnapshotDriftCheck
|
|
11
|
+
SAMPLE_SIZE = 200
|
|
12
|
+
EPSILON = BigDecimal("0.00000001")
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
16
|
+
return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
|
|
17
|
+
|
|
18
|
+
sampled_ids = LlmCostTracker::Call
|
|
19
|
+
.where.not(pricing_snapshot: nil)
|
|
20
|
+
.where(cost_status: %w[complete free])
|
|
21
|
+
.order(id: :desc)
|
|
22
|
+
.limit(SAMPLE_SIZE)
|
|
23
|
+
.pluck(:id)
|
|
24
|
+
return Check.new(:ok, "pricing snapshot drift", "no snapshotted calls to inspect") if sampled_ids.empty?
|
|
25
|
+
|
|
26
|
+
calls_by_id = LlmCostTracker::Call.where(id: sampled_ids).index_by(&:id)
|
|
27
|
+
line_items_by_call = LlmCostTracker::CallLineItem
|
|
28
|
+
.where(llm_cost_tracker_call_id: sampled_ids, unit: "token")
|
|
29
|
+
.group_by(&:llm_cost_tracker_call_id)
|
|
30
|
+
|
|
31
|
+
drifted = sampled_ids.flat_map do |id|
|
|
32
|
+
call = calls_by_id[id]
|
|
33
|
+
rates = rates_for(call.pricing_snapshot)
|
|
34
|
+
next [] if rates.nil? || rates.empty?
|
|
35
|
+
|
|
36
|
+
(line_items_by_call[id] || []).filter_map { |item| drift_message_for(item, rates, call_id: id) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
return ok_check(sampled_ids.size) if drifted.empty?
|
|
40
|
+
|
|
41
|
+
Check.new(
|
|
42
|
+
:warn,
|
|
43
|
+
"pricing snapshot drift",
|
|
44
|
+
"line item cost diverges from pricing_snapshot rate in #{drifted.size} cases across " \
|
|
45
|
+
"#{sampled_ids.size} sampled calls: #{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def ok_check(sample_size)
|
|
52
|
+
Check.new(:ok, "pricing snapshot drift",
|
|
53
|
+
"line item costs match pricing_snapshot rates in #{sample_size} sampled calls")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rates_for(snapshot)
|
|
57
|
+
rates = snapshot.is_a?(Hash) ? (snapshot["rates"] || snapshot[:rates]) : nil
|
|
58
|
+
rates.is_a?(Hash) ? rates : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def drift_message_for(line_item, rates, call_id:)
|
|
62
|
+
return nil unless line_item.price_key
|
|
63
|
+
|
|
64
|
+
rate = rates[line_item.price_key.to_s] || rates[line_item.price_key.to_sym]
|
|
65
|
+
return nil unless rate.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
rate_amount = decimal(rate["amount"] || rate[:amount])
|
|
68
|
+
rate_quantity = decimal(rate["quantity"] || rate[:quantity])
|
|
69
|
+
return nil if rate_amount.nil? || rate_quantity.nil? || rate_quantity.zero?
|
|
70
|
+
|
|
71
|
+
expected = (decimal(line_item.quantity) * rate_amount) / rate_quantity
|
|
72
|
+
actual = decimal(line_item.cost) || BigDecimal("0")
|
|
73
|
+
return nil if (expected - actual).abs <= EPSILON
|
|
74
|
+
|
|
75
|
+
"##{call_id}.#{line_item.price_key}: expected=#{expected.round(8).to_s('F')} stored=#{actual.to_s('F')}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def decimal(value)
|
|
79
|
+
return nil if value.nil?
|
|
80
|
+
|
|
81
|
+
BigDecimal(value.to_s)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ledger"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Doctor
|
|
7
|
+
module Probe
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def table_exists?(name)
|
|
11
|
+
LlmCostTracker::Call.connection.data_source_exists?(name)
|
|
12
|
+
rescue StandardError
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check"
|
|
4
|
+
require_relative "probe"
|
|
5
|
+
require_relative "../ledger"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
class Doctor
|
|
9
|
+
class SchemaCheck
|
|
10
|
+
def initialize(name:, schema:, table:, optional: false, install_command: "llm_cost_tracker:install")
|
|
11
|
+
@name = name
|
|
12
|
+
@schema = schema
|
|
13
|
+
@table = table
|
|
14
|
+
@optional = optional
|
|
15
|
+
@install_command = install_command
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
20
|
+
return if @optional && !Probe.table_exists?(@table)
|
|
21
|
+
|
|
22
|
+
errors = @schema.current_schema_errors
|
|
23
|
+
return Check.new(:ok, @name, "#{@table} exists") if errors.empty?
|
|
24
|
+
|
|
25
|
+
Check.new(
|
|
26
|
+
:error,
|
|
27
|
+
@name,
|
|
28
|
+
"current schema required; #{errors.join('; ')}; " \
|
|
29
|
+
"run bin/rails generate #{@install_command} && bin/rails db:migrate"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|