llm_cost_tracker 0.11.0 → 0.12.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 +55 -0
- data/README.md +7 -4
- data/app/assets/llm_cost_tracker/application.css +8 -7
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
- data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
- data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
- data/config/routes.rb +2 -3
- data/lib/llm_cost_tracker/budget.rb +24 -26
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/capture/sse.rb +1 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -44
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +5 -69
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +8 -18
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
- data/lib/llm_cost_tracker/parsers.rb +139 -26
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -278
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -3
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +1 -1
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +81 -55
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "psych"
|
|
4
|
-
|
|
5
|
-
require_relative "../errors"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Billing
|
|
9
|
-
RATE_BASES = %i[
|
|
10
|
-
per_million_tokens
|
|
11
|
-
per_million_characters
|
|
12
|
-
per_request
|
|
13
|
-
per_1k_requests
|
|
14
|
-
per_session
|
|
15
|
-
per_hour
|
|
16
|
-
per_gb_day
|
|
17
|
-
per_image
|
|
18
|
-
].freeze
|
|
19
|
-
|
|
20
|
-
RATE_BASIS_QUANTITIES = {
|
|
21
|
-
per_million_tokens: 1_000_000,
|
|
22
|
-
per_million_characters: 1_000_000,
|
|
23
|
-
per_request: 1,
|
|
24
|
-
per_1k_requests: 1_000,
|
|
25
|
-
per_session: 1,
|
|
26
|
-
per_hour: 1,
|
|
27
|
-
per_gb_day: 1,
|
|
28
|
-
per_image: 1
|
|
29
|
-
}.freeze
|
|
30
|
-
|
|
31
|
-
DEFAULT_RATE_BASIS_BY_UNIT = {
|
|
32
|
-
token: :per_million_tokens,
|
|
33
|
-
character: :per_million_characters,
|
|
34
|
-
request: :per_request,
|
|
35
|
-
session: :per_session,
|
|
36
|
-
hour: :per_hour,
|
|
37
|
-
image: :per_image
|
|
38
|
-
}.freeze
|
|
39
|
-
|
|
40
|
-
module Components
|
|
41
|
-
Component = Data.define(
|
|
42
|
-
:key,
|
|
43
|
-
:kind,
|
|
44
|
-
:direction,
|
|
45
|
-
:modality,
|
|
46
|
-
:cache_state,
|
|
47
|
-
:unit,
|
|
48
|
-
:category,
|
|
49
|
-
:token_key,
|
|
50
|
-
:cost_key,
|
|
51
|
-
:rate_basis
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
|
|
55
|
-
DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
|
|
56
|
-
|
|
57
|
-
def self.load_registry
|
|
58
|
-
Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
|
|
59
|
-
.map { |attributes| build(attributes) }
|
|
60
|
-
.freeze
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.build(attributes)
|
|
64
|
-
missing = REQUIRED_FIELDS - attributes.keys
|
|
65
|
-
raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
|
|
66
|
-
|
|
67
|
-
unit = attributes.fetch(:unit).to_sym
|
|
68
|
-
rate_basis = attributes[:rate_basis]&.to_sym || Billing::DEFAULT_RATE_BASIS_BY_UNIT[unit]
|
|
69
|
-
if rate_basis.nil?
|
|
70
|
-
raise Error, "components.yml entry needs rate_basis for unit #{unit.inspect}: #{attributes.inspect}"
|
|
71
|
-
end
|
|
72
|
-
unless Billing::RATE_BASES.include?(rate_basis)
|
|
73
|
-
raise Error, "components.yml entry has unknown rate_basis #{rate_basis.inspect}: #{attributes.inspect}"
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
Component.new(
|
|
77
|
-
key: attributes.fetch(:key).to_sym,
|
|
78
|
-
kind: attributes.fetch(:kind).to_sym,
|
|
79
|
-
direction: attributes.fetch(:direction).to_sym,
|
|
80
|
-
modality: attributes.fetch(:modality).to_sym,
|
|
81
|
-
cache_state: attributes.fetch(:cache_state).to_sym,
|
|
82
|
-
unit: unit,
|
|
83
|
-
category: attributes.fetch(:category).to_sym,
|
|
84
|
-
token_key: attributes[:token_key]&.to_sym,
|
|
85
|
-
cost_key: attributes[:cost_key]&.to_sym,
|
|
86
|
-
rate_basis: rate_basis
|
|
87
|
-
)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
REGISTRY = load_registry
|
|
91
|
-
BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
|
|
92
|
-
TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "check"
|
|
4
|
-
require_relative "../ingestion"
|
|
5
|
-
|
|
6
|
-
module LlmCostTracker
|
|
7
|
-
class Doctor
|
|
8
|
-
class CaptureVerifier
|
|
9
|
-
class << self
|
|
10
|
-
def call
|
|
11
|
-
new.checks
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def report(checks = call)
|
|
15
|
-
(["LLM Cost Tracker capture verification"] + checks.map do |check|
|
|
16
|
-
"[#{check.status}] #{check.name}: #{check.message}"
|
|
17
|
-
end).join("\n")
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def healthy?(checks = call)
|
|
21
|
-
checks.none? { |check| check.status == :error }
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def checks
|
|
26
|
-
[
|
|
27
|
-
enabled_check,
|
|
28
|
-
*integration_checks,
|
|
29
|
-
*storage_checks
|
|
30
|
-
].compact
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def enabled_check
|
|
36
|
-
return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
|
|
37
|
-
|
|
38
|
-
Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def integration_checks
|
|
42
|
-
enabled = LlmCostTracker.configuration.instrumented_integrations
|
|
43
|
-
if enabled.empty?
|
|
44
|
-
return [
|
|
45
|
-
Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
|
|
46
|
-
]
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
LlmCostTracker::Integrations.checks.map do |check|
|
|
50
|
-
check.with(name: "sdk integration #{check.name}")
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def storage_checks
|
|
55
|
-
LlmCostTracker::Ingestion.verify
|
|
56
|
-
rescue LlmCostTracker::Error => e
|
|
57
|
-
[Check.new(:error, "storage", e.message)]
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bigdecimal"
|
|
4
|
-
|
|
5
|
-
require_relative "check"
|
|
6
|
-
require_relative "probe"
|
|
7
|
-
require_relative "../ledger/rollups"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
class Doctor
|
|
11
|
-
class CostDriftCheck
|
|
12
|
-
SAMPLE_SIZE = 200
|
|
13
|
-
EPSILON = BigDecimal("0.00000001")
|
|
14
|
-
|
|
15
|
-
def call
|
|
16
|
-
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
17
|
-
return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
|
|
18
|
-
|
|
19
|
-
sampled = LlmCostTracker::Call
|
|
20
|
-
.where.not(total_cost: nil)
|
|
21
|
-
.where(cost_status: %w[complete free partial])
|
|
22
|
-
.order(id: :desc)
|
|
23
|
-
.limit(SAMPLE_SIZE)
|
|
24
|
-
.pluck(:id, :total_cost, :cost_status)
|
|
25
|
-
return Check.new(:ok, "cost drift", "no priced calls to inspect") if sampled.empty?
|
|
26
|
-
|
|
27
|
-
line_item_totals = LlmCostTracker::CallLineItem
|
|
28
|
-
.where(llm_cost_tracker_call_id: sampled.map(&:first))
|
|
29
|
-
.where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
|
|
30
|
-
.group(:llm_cost_tracker_call_id)
|
|
31
|
-
.sum(:cost)
|
|
32
|
-
|
|
33
|
-
drifted = sampled.filter_map do |id, total_cost, cost_status|
|
|
34
|
-
line_total = line_item_totals[id] || BigDecimal("0")
|
|
35
|
-
header = BigDecimal(total_cost.to_s)
|
|
36
|
-
next if cost_status == "partial" && header >= line_total
|
|
37
|
-
next if (header - line_total).abs <= EPSILON
|
|
38
|
-
|
|
39
|
-
"##{id}: header=#{header.to_s('F')} line_items=#{line_total.to_s('F')}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
if drifted.empty?
|
|
43
|
-
return Check.new(:ok, "cost drift",
|
|
44
|
-
"header total_cost matches line items in #{sampled.size} sampled calls")
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
Check.new(
|
|
48
|
-
:warn,
|
|
49
|
-
"cost drift",
|
|
50
|
-
"header total_cost diverges from line items in #{drifted.size}/#{sampled.size} sampled calls: " \
|
|
51
|
-
"#{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
@@ -1,164 +0,0 @@
|
|
|
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
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
@@ -1,85 +0,0 @@
|
|
|
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
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rails/generators"
|
|
4
|
-
require "rails/generators/active_record"
|
|
5
|
-
|
|
6
|
-
module LlmCostTracker
|
|
7
|
-
module Generators
|
|
8
|
-
class ReconciliationGenerator < Rails::Generators::Base
|
|
9
|
-
include ActiveRecord::Generators::Migration
|
|
10
|
-
|
|
11
|
-
source_root File.expand_path("templates", __dir__)
|
|
12
|
-
|
|
13
|
-
desc "Creates the optional invoice reconciliation tables. Requires provider admin/org-level API keys."
|
|
14
|
-
|
|
15
|
-
def create_migration_file
|
|
16
|
-
migration_template(
|
|
17
|
-
"create_llm_cost_tracker_reconciliation.rb.erb",
|
|
18
|
-
"db/migrate/create_llm_cost_tracker_reconciliation.rb"
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def warn_about_admin_keys
|
|
23
|
-
say "Reconciliation requires admin/org-level API keys (OpenAI sk-admin-..., Anthropic admin keys, " \
|
|
24
|
-
"GCP billing.viewer service accounts). Do NOT use the runtime inference key.", :yellow
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def migration_version
|
|
30
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
-
|
|
3
|
-
class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
-
def change
|
|
5
|
-
create_table :llm_cost_tracker_provider_invoices, if_not_exists: true do |t|
|
|
6
|
-
t.string :source, null: false
|
|
7
|
-
t.date :period_start, null: false
|
|
8
|
-
t.date :period_end, null: false
|
|
9
|
-
t.string :external_id, null: false
|
|
10
|
-
t.decimal :billed_amount, precision: 20, scale: 8
|
|
11
|
-
t.string :currency, null: false, default: "USD"
|
|
12
|
-
if postgresql?
|
|
13
|
-
t.jsonb :metadata, null: false, default: {}
|
|
14
|
-
elsif mysql?
|
|
15
|
-
t.json :metadata, null: false
|
|
16
|
-
else
|
|
17
|
-
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
18
|
-
end
|
|
19
|
-
t.datetime :imported_at, null: false
|
|
20
|
-
|
|
21
|
-
t.timestamps
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
|
|
25
|
-
t.string :source, null: false
|
|
26
|
-
t.string :provider, null: false, default: ""
|
|
27
|
-
t.string :cursor
|
|
28
|
-
t.date :window_start
|
|
29
|
-
t.date :window_end
|
|
30
|
-
t.string :state, null: false
|
|
31
|
-
t.text :last_error
|
|
32
|
-
t.integer :rows_imported, null: false, default: 0
|
|
33
|
-
t.datetime :started_at, null: false
|
|
34
|
-
t.datetime :finished_at
|
|
35
|
-
|
|
36
|
-
t.timestamps
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true,
|
|
40
|
-
if_not_exists: true
|
|
41
|
-
add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
|
|
42
|
-
if_not_exists: true
|
|
43
|
-
if postgresql?
|
|
44
|
-
add_index :llm_cost_tracker_provider_invoices, :metadata, using: :gin,
|
|
45
|
-
if_not_exists: true
|
|
46
|
-
end
|
|
47
|
-
add_index :llm_cost_tracker_provider_invoice_imports, %i[source provider started_at],
|
|
48
|
-
if_not_exists: true
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
def postgresql?
|
|
54
|
-
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def mysql?
|
|
58
|
-
LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
-
|
|
3
|
-
class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
-
disable_ddl_transaction!
|
|
5
|
-
|
|
6
|
-
TABLE = :llm_cost_tracker_provider_invoice_imports
|
|
7
|
-
OLD_INDEX = %i[source started_at].freeze
|
|
8
|
-
NEW_INDEX = %i[source provider started_at].freeze
|
|
9
|
-
|
|
10
|
-
def up
|
|
11
|
-
return unless table_exists?(TABLE)
|
|
12
|
-
|
|
13
|
-
add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
|
|
14
|
-
if postgresql?
|
|
15
|
-
remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
|
|
16
|
-
add_index TABLE, NEW_INDEX, algorithm: :concurrently, if_not_exists: true
|
|
17
|
-
else
|
|
18
|
-
remove_index TABLE, column: OLD_INDEX, if_exists: true
|
|
19
|
-
add_index TABLE, NEW_INDEX, if_not_exists: true
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def down
|
|
24
|
-
return unless table_exists?(TABLE)
|
|
25
|
-
|
|
26
|
-
remove_index TABLE, column: NEW_INDEX, if_exists: true
|
|
27
|
-
add_index TABLE, OLD_INDEX, if_not_exists: true
|
|
28
|
-
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def postgresql?
|
|
34
|
-
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
35
|
-
end
|
|
36
|
-
end
|