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,54 +1,84 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bigdecimal"
|
|
3
|
+
require "bigdecimal/util"
|
|
4
4
|
|
|
5
5
|
require_relative "period"
|
|
6
|
-
require_relative "rollups/upsert_sql"
|
|
7
6
|
|
|
8
7
|
module LlmCostTracker
|
|
9
8
|
module Ledger
|
|
10
|
-
|
|
11
|
-
DEFAULT_CURRENCY = "USD"
|
|
12
|
-
|
|
9
|
+
module Rollups
|
|
13
10
|
class << self
|
|
14
|
-
def increment!(
|
|
15
|
-
|
|
11
|
+
def increment!(events)
|
|
12
|
+
events = Array(events).select(&:total_cost)
|
|
13
|
+
return if events.empty?
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
LlmCostTracker::CallRollup.increment_all(period_rows_for_events(events))
|
|
18
16
|
end
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
ROLLUP_INCREMENT_ATTEMPTS = 3
|
|
19
|
+
ROLLUP_INCREMENT_BASE_DELAY_SECONDS = 0.05
|
|
20
|
+
REBUILD_INSERT_SLICE = 1_000
|
|
21
|
+
private_constant :ROLLUP_INCREMENT_ATTEMPTS, :ROLLUP_INCREMENT_BASE_DELAY_SECONDS, :REBUILD_INSERT_SLICE
|
|
22
|
+
|
|
23
|
+
def increment_safely!(events)
|
|
24
|
+
attempt = 0
|
|
25
|
+
begin
|
|
26
|
+
attempt += 1
|
|
27
|
+
increment!(events)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
raise if LlmCostTracker::Call.connection.open_transactions.positive?
|
|
30
|
+
|
|
31
|
+
if attempt < ROLLUP_INCREMENT_ATTEMPTS
|
|
32
|
+
sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
|
|
33
|
+
retry
|
|
34
|
+
end
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
LlmCostTracker::Logging.warn(
|
|
37
|
+
"Rollup increment failed for #{events.size} events after #{attempt} attempts: " \
|
|
38
|
+
"#{e.class}: #{e.message}"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
25
41
|
end
|
|
26
42
|
|
|
27
|
-
def decrement!(
|
|
28
|
-
|
|
29
|
-
return if
|
|
43
|
+
def decrement!(records)
|
|
44
|
+
buckets = bucket_totals(records)
|
|
45
|
+
return if buckets.empty?
|
|
46
|
+
|
|
47
|
+
LlmCostTracker::CallRollup.decrement(buckets)
|
|
48
|
+
end
|
|
30
49
|
|
|
31
|
-
|
|
50
|
+
def rebuild!
|
|
51
|
+
accumulated = accumulate_priced_calls
|
|
52
|
+
LlmCostTracker::CallRollup.transaction do
|
|
53
|
+
LlmCostTracker::CallRollup.delete_all
|
|
54
|
+
rows_from_buckets(accumulated).each_slice(REBUILD_INSERT_SLICE) do |rows|
|
|
55
|
+
LlmCostTracker::CallRollup.increment_all(rows)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
accumulated.size
|
|
32
59
|
end
|
|
33
60
|
|
|
34
61
|
private
|
|
35
62
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
period: name,
|
|
42
|
-
period_start: Period.bucket(period, event.tracked_at),
|
|
43
|
-
currency: currency,
|
|
44
|
-
provider: provider,
|
|
45
|
-
total_cost: event.total_cost
|
|
46
|
-
}
|
|
63
|
+
def accumulate_priced_calls
|
|
64
|
+
accumulated = Hash.new { |totals, key| totals[key] = BigDecimal("0") }
|
|
65
|
+
priced_calls.in_batches do |batch|
|
|
66
|
+
bucket_totals(batch).each { |key, total| accumulated[key] += total }
|
|
47
67
|
end
|
|
68
|
+
accumulated
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def priced_calls
|
|
72
|
+
LlmCostTracker::Call.where.not(total_cost: nil)
|
|
73
|
+
.select(:id, :total_cost, :pricing_snapshot, :provider, :tracked_at)
|
|
48
74
|
end
|
|
49
75
|
|
|
50
76
|
def period_rows_for_events(events)
|
|
51
|
-
|
|
77
|
+
rows_from_buckets(bucket_totals(events))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rows_from_buckets(buckets)
|
|
81
|
+
buckets.map do |(period, period_start, currency, provider), total_cost|
|
|
52
82
|
{
|
|
53
83
|
period: period,
|
|
54
84
|
period_start: period_start,
|
|
@@ -59,84 +89,23 @@ module LlmCostTracker
|
|
|
59
89
|
end
|
|
60
90
|
end
|
|
61
91
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
provider = event.provider.to_s
|
|
66
|
-
Period::PERIODS.each do |period, name|
|
|
67
|
-
key = [name, Period.bucket(period, event.tracked_at), currency, provider]
|
|
68
|
-
totals[key] += BigDecimal(event.total_cost.to_s)
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
92
|
+
def bucket_totals(entries)
|
|
93
|
+
entries.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |entry, totals|
|
|
94
|
+
next unless entry.total_cost
|
|
72
95
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
currency = currency_from_snapshot(pricing_snapshot)
|
|
79
|
-
provider_key = provider.to_s
|
|
80
|
-
Period::PERIODS.each_key do |period|
|
|
81
|
-
totals[[period, Period.bucket(period, tracked_at), currency, provider_key]] += total_cost
|
|
96
|
+
currency = currency_from_snapshot(entry.pricing_snapshot)
|
|
97
|
+
provider = entry.provider.to_s
|
|
98
|
+
Period::PERIODS.each do |period|
|
|
99
|
+
key = [period.to_s, Period.bucket(period, entry.tracked_at), currency, provider]
|
|
100
|
+
totals[key] += entry.total_cost.to_d
|
|
82
101
|
end
|
|
83
102
|
end
|
|
84
103
|
end
|
|
85
104
|
|
|
86
|
-
def apply_decrements(totals)
|
|
87
|
-
now = Time.now.utc
|
|
88
|
-
buckets_by_period = totals.each_with_object({}) do |(key, amount), grouped|
|
|
89
|
-
period, period_start, currency, provider = key
|
|
90
|
-
grouped[[period, currency, provider]] ||= {}
|
|
91
|
-
grouped[[period, currency, provider]][period_start] = amount
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
conn = LlmCostTracker::CallRollup.connection
|
|
95
|
-
table = LlmCostTracker::CallRollup.quoted_table_name
|
|
96
|
-
period_col = conn.quote_column_name("period")
|
|
97
|
-
start_col = conn.quote_column_name("period_start")
|
|
98
|
-
currency_col = conn.quote_column_name("currency")
|
|
99
|
-
provider_col = conn.quote_column_name("provider")
|
|
100
|
-
total_col = conn.quote_column_name("total_cost")
|
|
101
|
-
updated_col = conn.quote_column_name("updated_at")
|
|
102
|
-
|
|
103
|
-
buckets_by_period.each do |(period, currency, provider), by_start|
|
|
104
|
-
case_clauses = by_start.map do |period_start, amount|
|
|
105
|
-
"WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
|
|
106
|
-
end.join(" ")
|
|
107
|
-
starts = by_start.keys.map { |period_start| conn.quote(period_start) }.join(", ")
|
|
108
|
-
|
|
109
|
-
conn.execute(
|
|
110
|
-
"UPDATE #{table} " \
|
|
111
|
-
"SET #{total_col} = GREATEST(0, #{total_col} - CASE #{case_clauses} ELSE 0 END), " \
|
|
112
|
-
"#{updated_col} = #{conn.quote(now)} " \
|
|
113
|
-
"WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
|
|
114
|
-
"AND #{currency_col} = #{conn.quote(currency)} " \
|
|
115
|
-
"AND #{provider_col} = #{conn.quote(provider)} " \
|
|
116
|
-
"AND #{start_col} IN (#{starts})"
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
105
|
def currency_from_snapshot(snapshot)
|
|
122
|
-
value = (snapshot.is_a?(Hash) &&
|
|
106
|
+
value = (snapshot.is_a?(Hash) && snapshot["currency"]) || LlmCostTracker::DEFAULT_CURRENCY
|
|
123
107
|
value.to_s.upcase
|
|
124
108
|
end
|
|
125
|
-
|
|
126
|
-
def upsert_call_rollups(rows)
|
|
127
|
-
LlmCostTracker::CallRollup.upsert_all(
|
|
128
|
-
rows,
|
|
129
|
-
on_duplicate: Ledger::Rollups::UpsertSql.call,
|
|
130
|
-
record_timestamps: true,
|
|
131
|
-
unique_by: call_rollups_unique_by
|
|
132
|
-
)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def call_rollups_unique_by
|
|
136
|
-
return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
|
|
137
|
-
|
|
138
|
-
%i[period period_start currency provider]
|
|
139
|
-
end
|
|
140
109
|
end
|
|
141
110
|
end
|
|
142
111
|
end
|
|
@@ -32,22 +32,21 @@ module LlmCostTracker
|
|
|
32
32
|
raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
PG_PERIOD_FORMATS = { day: "YYYY-MM-DD", month: "YYYY-MM" }.freeze
|
|
36
|
+
MYSQL_PERIOD_FORMATS = { day: "%Y-%m-%d", month: "%Y-%m" }.freeze
|
|
37
|
+
private_constant :PG_PERIOD_FORMATS, :MYSQL_PERIOD_FORMATS
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def json_column_type?(column, adapter_value)
|
|
45
|
-
sql_type = column.sql_type.to_s.downcase
|
|
46
|
-
if postgresql?(adapter_value)
|
|
47
|
-
column.type == :jsonb || sql_type == "jsonb"
|
|
39
|
+
def period_bucket_sql(connection, period, column)
|
|
40
|
+
period = period.to_sym
|
|
41
|
+
if postgresql?(connection)
|
|
42
|
+
"TO_CHAR(DATE_TRUNC('#{period}', #{column}), '#{PG_PERIOD_FORMATS.fetch(period)}')"
|
|
43
|
+
elsif mysql?(connection)
|
|
44
|
+
"DATE_FORMAT(#{column}, '#{MYSQL_PERIOD_FORMATS.fetch(period)}')"
|
|
48
45
|
else
|
|
49
|
-
|
|
46
|
+
ensure_supported!(connection)
|
|
50
47
|
end
|
|
48
|
+
rescue KeyError
|
|
49
|
+
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
private
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
require_relative "adapter"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Ledger
|
|
9
|
+
module Schema
|
|
10
|
+
module Base
|
|
11
|
+
def model
|
|
12
|
+
@model ||= LlmCostTracker.const_get(detect_model_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def columns(*names)
|
|
16
|
+
@required_columns = names.map(&:to_s).freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def required_columns
|
|
20
|
+
@required_columns || [].freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def current_schema_errors
|
|
24
|
+
connection = model.connection
|
|
25
|
+
Adapter.ensure_supported!(connection)
|
|
26
|
+
table_name = model.table_name
|
|
27
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
28
|
+
|
|
29
|
+
columns_hash = model.columns_hash
|
|
30
|
+
cache = @schema_capabilities
|
|
31
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns_hash)
|
|
32
|
+
|
|
33
|
+
errors = column_errors(columns_hash)
|
|
34
|
+
@schema_capabilities = { columns: columns_hash, errors: errors }
|
|
35
|
+
errors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def column_errors(columns_hash)
|
|
41
|
+
missing = required_columns - columns_hash.keys
|
|
42
|
+
missing.any? ? ["missing columns: #{missing.join(', ')}"] : []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def detect_model_name
|
|
46
|
+
name.delete_prefix("#{Schema.name}::").singularize
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -1,89 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "base"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Ledger
|
|
7
7
|
module Schema
|
|
8
8
|
module CallLineItems
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
REQUIRED_INDEX_COLUMNS = [
|
|
34
|
-
%w[llm_cost_tracker_call_id position]
|
|
35
|
-
].freeze
|
|
36
|
-
|
|
37
|
-
class << self
|
|
38
|
-
def current_schema_errors
|
|
39
|
-
connection = LlmCostTracker::Call.connection
|
|
40
|
-
Adapter.ensure_supported!(connection)
|
|
41
|
-
table_name = LlmCostTracker::CallLineItem.table_name
|
|
42
|
-
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
43
|
-
|
|
44
|
-
columns = LlmCostTracker::CallLineItem.columns_hash
|
|
45
|
-
cache = @schema_capabilities
|
|
46
|
-
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
47
|
-
|
|
48
|
-
errors = compute_errors(connection, table_name, columns)
|
|
49
|
-
@schema_capabilities = { columns: columns, errors: errors }
|
|
50
|
-
errors
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
def compute_errors(connection, table_name, columns)
|
|
56
|
-
errors = []
|
|
57
|
-
missing = REQUIRED_COLUMNS - columns.keys
|
|
58
|
-
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
59
|
-
errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
|
|
60
|
-
errors.concat(missing_index_errors(connection, table_name))
|
|
61
|
-
errors << missing_fk_error(connection, table_name) if missing_fk?(connection, table_name)
|
|
62
|
-
errors.compact
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def missing_index_errors(connection, table_name)
|
|
66
|
-
existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
|
|
67
|
-
REQUIRED_INDEX_COLUMNS.filter_map do |required|
|
|
68
|
-
next if existing.any? { |columns| columns == required }
|
|
69
|
-
|
|
70
|
-
"missing index on (#{required.join(', ')})"
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def missing_fk?(connection, table_name)
|
|
75
|
-
connection.foreign_keys(table_name).none? do |fk|
|
|
76
|
-
fk.column.to_s == "llm_cost_tracker_call_id" &&
|
|
77
|
-
fk.to_table.to_s == "llm_cost_tracker_calls"
|
|
78
|
-
end
|
|
79
|
-
rescue NotImplementedError, NoMethodError
|
|
80
|
-
false
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def missing_fk_error(_connection, _table_name)
|
|
84
|
-
"missing foreign key on llm_cost_tracker_call_id referencing llm_cost_tracker_calls"
|
|
85
|
-
end
|
|
86
|
-
end
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
columns :llm_cost_tracker_call_id,
|
|
12
|
+
:position,
|
|
13
|
+
:kind,
|
|
14
|
+
:direction,
|
|
15
|
+
:modality,
|
|
16
|
+
:cache_state,
|
|
17
|
+
:quantity,
|
|
18
|
+
:unit,
|
|
19
|
+
:rate_amount,
|
|
20
|
+
:rate_quantity,
|
|
21
|
+
:cost,
|
|
22
|
+
:currency,
|
|
23
|
+
:cost_status,
|
|
24
|
+
:pricing_basis,
|
|
25
|
+
:price_key,
|
|
26
|
+
:price_source,
|
|
27
|
+
:price_source_version,
|
|
28
|
+
:provider_field,
|
|
29
|
+
:provider_item_id,
|
|
30
|
+
:details,
|
|
31
|
+
:created_at
|
|
87
32
|
end
|
|
88
33
|
end
|
|
89
34
|
end
|
|
@@ -1,46 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "base"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Ledger
|
|
7
7
|
module Schema
|
|
8
8
|
module CallRollups
|
|
9
|
-
|
|
10
|
-
UNIQUE_COLUMNS = %i[period period_start currency provider].freeze
|
|
9
|
+
extend Base
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
def current_schema_errors
|
|
14
|
-
connection = LlmCostTracker::CallRollup.connection
|
|
15
|
-
Adapter.ensure_supported!(connection)
|
|
16
|
-
table_name = LlmCostTracker::CallRollup.table_name
|
|
17
|
-
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
18
|
-
|
|
19
|
-
columns = LlmCostTracker::CallRollup.columns_hash
|
|
20
|
-
cache = @schema_capabilities
|
|
21
|
-
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
22
|
-
|
|
23
|
-
errors = compute_errors(connection, table_name, columns)
|
|
24
|
-
@schema_capabilities = { columns: columns, errors: errors }
|
|
25
|
-
errors
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def compute_errors(connection, table_name, columns)
|
|
31
|
-
errors = []
|
|
32
|
-
missing = REQUIRED_COLUMNS - columns.keys
|
|
33
|
-
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
34
|
-
unless unique_period_index?(connection, table_name)
|
|
35
|
-
errors << "missing unique index: period, period_start, currency, provider"
|
|
36
|
-
end
|
|
37
|
-
errors
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def unique_period_index?(connection, table_name)
|
|
41
|
-
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
11
|
+
columns :period, :period_start, :currency, :provider, :total_cost, :created_at, :updated_at
|
|
44
12
|
end
|
|
45
13
|
end
|
|
46
14
|
end
|
|
@@ -1,51 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Ledger
|
|
5
7
|
module Schema
|
|
6
8
|
module CallTags
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
REQUIRED_INDEX_COLUMNS = [
|
|
10
|
-
%w[key value],
|
|
11
|
-
%w[llm_cost_tracker_call_id]
|
|
12
|
-
].freeze
|
|
13
|
-
|
|
14
|
-
class << self
|
|
15
|
-
def current_schema_errors
|
|
16
|
-
connection = LlmCostTracker::Call.connection
|
|
17
|
-
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
18
|
-
table_name = LlmCostTracker::CallTag.table_name
|
|
19
|
-
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
20
|
-
|
|
21
|
-
columns = LlmCostTracker::CallTag.columns_hash
|
|
22
|
-
cache = @schema_capabilities
|
|
23
|
-
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
24
|
-
|
|
25
|
-
errors = compute_errors(connection, table_name, columns)
|
|
26
|
-
@schema_capabilities = { columns: columns, errors: errors }
|
|
27
|
-
errors
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def compute_errors(connection, table_name, columns)
|
|
33
|
-
errors = []
|
|
34
|
-
missing = REQUIRED_COLUMNS - columns.keys
|
|
35
|
-
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
36
|
-
errors.concat(missing_index_errors(connection, table_name))
|
|
37
|
-
errors
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def missing_index_errors(connection, table_name)
|
|
41
|
-
existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
|
|
42
|
-
REQUIRED_INDEX_COLUMNS.filter_map do |required|
|
|
43
|
-
next if existing.any? { |columns| (required - columns).empty? }
|
|
9
|
+
extend Base
|
|
44
10
|
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
11
|
+
columns :llm_cost_tracker_call_id, :key, :value
|
|
49
12
|
end
|
|
50
13
|
end
|
|
51
14
|
end
|
|
@@ -1,109 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "base"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Ledger
|
|
7
7
|
module Schema
|
|
8
8
|
module Calls
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
REQUIRED_INDEXES = [
|
|
40
|
-
{ columns: :event_id, unique: true },
|
|
41
|
-
{ columns: :tracked_at },
|
|
42
|
-
{ columns: %i[provider tracked_at] },
|
|
43
|
-
{ columns: %i[model tracked_at] },
|
|
44
|
-
{ columns: :cost_status },
|
|
45
|
-
{ columns: :provider_response_id }
|
|
46
|
-
].freeze
|
|
47
|
-
private_constant :REQUIRED_INDEXES
|
|
48
|
-
|
|
49
|
-
class << self
|
|
50
|
-
def current_schema_errors
|
|
51
|
-
schema_capabilities.fetch(:current_schema_errors)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def missing_current_schema_columns
|
|
55
|
-
schema_capabilities.fetch(:missing_current_schema_columns)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def schema_capabilities
|
|
61
|
-
columns = LlmCostTracker::Call.columns_hash
|
|
62
|
-
adapter_name = LlmCostTracker::Call.connection.adapter_name
|
|
63
|
-
cache = @schema_capabilities
|
|
64
|
-
|
|
65
|
-
return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
|
|
66
|
-
cache.fetch(:adapter_name) == adapter_name
|
|
67
|
-
|
|
68
|
-
values = build_schema_capabilities(columns, adapter_name)
|
|
69
|
-
@schema_capabilities = { columns: columns, adapter_name: adapter_name, values: values }
|
|
70
|
-
values
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def build_schema_capabilities(columns, adapter_name)
|
|
74
|
-
Ledger::Schema::Adapter.ensure_supported!(adapter_name)
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
missing_current_schema_columns: missing_columns_for(columns),
|
|
78
|
-
current_schema_errors: schema_errors_for(columns, adapter_name)
|
|
79
|
-
}
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def schema_errors_for(columns, adapter_name)
|
|
83
|
-
errors = []
|
|
84
|
-
missing = missing_columns_for(columns)
|
|
85
|
-
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
86
|
-
errors.concat(Adapter.json_column_errors(columns["pricing_snapshot"], adapter_name, "pricing_snapshot"))
|
|
87
|
-
errors.concat(missing_index_errors)
|
|
88
|
-
errors
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def missing_index_errors
|
|
92
|
-
connection = LlmCostTracker::Call.connection
|
|
93
|
-
REQUIRED_INDEXES.filter_map do |spec|
|
|
94
|
-
next if connection.index_exists?(LlmCostTracker::Call.table_name, spec[:columns], **spec.except(:columns))
|
|
95
|
-
|
|
96
|
-
prefix = spec[:unique] ? "unique " : ""
|
|
97
|
-
"missing #{prefix}index: #{Array(spec[:columns]).join(', ')}"
|
|
98
|
-
end
|
|
99
|
-
rescue StandardError
|
|
100
|
-
[]
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def missing_columns_for(columns)
|
|
104
|
-
CURRENT_SCHEMA_COLUMNS - columns.keys
|
|
105
|
-
end
|
|
106
|
-
end
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
columns :event_id,
|
|
12
|
+
:provider,
|
|
13
|
+
:model,
|
|
14
|
+
:input_tokens,
|
|
15
|
+
:output_tokens,
|
|
16
|
+
:total_tokens,
|
|
17
|
+
:cache_read_input_tokens,
|
|
18
|
+
:cache_write_input_tokens,
|
|
19
|
+
:cache_write_extended_input_tokens,
|
|
20
|
+
:audio_input_tokens,
|
|
21
|
+
:audio_output_tokens,
|
|
22
|
+
:image_input_tokens,
|
|
23
|
+
:image_output_tokens,
|
|
24
|
+
:hidden_output_tokens,
|
|
25
|
+
:total_cost,
|
|
26
|
+
:latency_ms,
|
|
27
|
+
:stream,
|
|
28
|
+
:usage_source,
|
|
29
|
+
:provider_response_id,
|
|
30
|
+
:provider_project_id,
|
|
31
|
+
:provider_api_key_id,
|
|
32
|
+
:provider_workspace_id,
|
|
33
|
+
:batch,
|
|
34
|
+
:pricing_mode,
|
|
35
|
+
:cost_status,
|
|
36
|
+
:pricing_snapshot,
|
|
37
|
+
:tracked_at
|
|
107
38
|
end
|
|
108
39
|
end
|
|
109
40
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module Ingestion
|
|
9
|
+
module InboxEntries
|
|
10
|
+
extend Base
|
|
11
|
+
|
|
12
|
+
columns :event_id,
|
|
13
|
+
:total_cost,
|
|
14
|
+
:tracked_at,
|
|
15
|
+
:payload,
|
|
16
|
+
:locked_at,
|
|
17
|
+
:locked_by,
|
|
18
|
+
:attempts,
|
|
19
|
+
:last_error,
|
|
20
|
+
:created_at,
|
|
21
|
+
:updated_at
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|