llm_cost_tracker 0.9.0 → 0.11.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 +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -34,8 +34,8 @@ module LlmCostTracker
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
36
|
def period_rows(event)
|
|
37
|
-
currency =
|
|
38
|
-
provider =
|
|
37
|
+
currency = currency_from_snapshot(event.pricing_snapshot)
|
|
38
|
+
provider = event.provider.to_s
|
|
39
39
|
Period::PERIODS.map do |period, name|
|
|
40
40
|
{
|
|
41
41
|
period: name,
|
|
@@ -61,8 +61,8 @@ module LlmCostTracker
|
|
|
61
61
|
|
|
62
62
|
def call_rollups(events)
|
|
63
63
|
events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
|
|
64
|
-
currency =
|
|
65
|
-
provider =
|
|
64
|
+
currency = currency_from_snapshot(event.pricing_snapshot)
|
|
65
|
+
provider = event.provider.to_s
|
|
66
66
|
Period::PERIODS.each do |period, name|
|
|
67
67
|
key = [name, Period.bucket(period, event.tracked_at), currency, provider]
|
|
68
68
|
totals[key] += BigDecimal(event.total_cost.to_s)
|
|
@@ -118,20 +118,11 @@ module LlmCostTracker
|
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
def currency_for(event)
|
|
122
|
-
snapshot = event.respond_to?(:pricing_snapshot) ? event.pricing_snapshot : nil
|
|
123
|
-
currency_from_snapshot(snapshot)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
121
|
def currency_from_snapshot(snapshot)
|
|
127
122
|
value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
|
|
128
123
|
value.to_s.upcase
|
|
129
124
|
end
|
|
130
125
|
|
|
131
|
-
def provider_for(event)
|
|
132
|
-
(event.respond_to?(:provider) ? event.provider : nil).to_s
|
|
133
|
-
end
|
|
134
|
-
|
|
135
126
|
def upsert_call_rollups(rows)
|
|
136
127
|
LlmCostTracker::CallRollup.upsert_all(
|
|
137
128
|
rows,
|
|
@@ -42,6 +42,17 @@ module LlmCostTracker
|
|
|
42
42
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
43
43
|
|
|
44
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)
|
|
45
56
|
errors = []
|
|
46
57
|
missing = REQUIRED_COLUMNS - columns.keys
|
|
47
58
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
@@ -16,8 +16,20 @@ module LlmCostTracker
|
|
|
16
16
|
table_name = LlmCostTracker::CallRollup.table_name
|
|
17
17
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
18
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)
|
|
19
31
|
errors = []
|
|
20
|
-
missing = REQUIRED_COLUMNS -
|
|
32
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
21
33
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
22
34
|
unless unique_period_index?(connection, table_name)
|
|
23
35
|
errors << "missing unique index: period, period_start, currency, provider"
|
|
@@ -25,8 +37,6 @@ module LlmCostTracker
|
|
|
25
37
|
errors
|
|
26
38
|
end
|
|
27
39
|
|
|
28
|
-
private
|
|
29
|
-
|
|
30
40
|
def unique_period_index?(connection, table_name)
|
|
31
41
|
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
32
42
|
end
|
|
@@ -19,6 +19,17 @@ module LlmCostTracker
|
|
|
19
19
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
20
20
|
|
|
21
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)
|
|
22
33
|
errors = []
|
|
23
34
|
missing = REQUIRED_COLUMNS - columns.keys
|
|
24
35
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
@@ -28,15 +28,25 @@ module LlmCostTracker
|
|
|
28
28
|
table_name = LlmCostTracker::Ingestion::InboxEntry.table_name
|
|
29
29
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
30
30
|
|
|
31
|
+
columns = LlmCostTracker::Ingestion::InboxEntry.columns_hash
|
|
32
|
+
cache = @schema_capabilities
|
|
33
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
34
|
+
|
|
35
|
+
errors = compute_errors(connection, table_name, columns)
|
|
36
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
37
|
+
errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def compute_errors(connection, table_name, columns)
|
|
31
43
|
errors = []
|
|
32
|
-
missing = REQUIRED_COLUMNS -
|
|
44
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
33
45
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
34
46
|
errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
|
|
35
47
|
errors
|
|
36
48
|
end
|
|
37
49
|
|
|
38
|
-
private
|
|
39
|
-
|
|
40
50
|
def event_id_unique_index?(connection, table_name)
|
|
41
51
|
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
42
52
|
end
|
|
@@ -23,15 +23,25 @@ module LlmCostTracker
|
|
|
23
23
|
table_name = LlmCostTracker::Ingestion::Lease.table_name
|
|
24
24
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
25
25
|
|
|
26
|
+
columns = LlmCostTracker::Ingestion::Lease.columns_hash
|
|
27
|
+
cache = @schema_capabilities
|
|
28
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
29
|
+
|
|
30
|
+
errors = compute_errors(connection, table_name, columns)
|
|
31
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
32
|
+
errors
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def compute_errors(connection, table_name, columns)
|
|
26
38
|
errors = []
|
|
27
|
-
missing = REQUIRED_COLUMNS -
|
|
39
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
28
40
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
29
41
|
errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
|
|
30
42
|
errors
|
|
31
43
|
end
|
|
32
44
|
|
|
33
|
-
private
|
|
34
|
-
|
|
35
45
|
def name_unique_index?(connection, table_name)
|
|
36
46
|
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
37
47
|
end
|
|
@@ -7,10 +7,10 @@ module LlmCostTracker
|
|
|
7
7
|
module Schema
|
|
8
8
|
module ProviderInvoiceImports
|
|
9
9
|
REQUIRED_COLUMNS = %w[
|
|
10
|
-
source cursor window_start window_end state last_error
|
|
10
|
+
source provider cursor window_start window_end state last_error
|
|
11
11
|
rows_imported started_at finished_at
|
|
12
12
|
].freeze
|
|
13
|
-
|
|
13
|
+
SOURCE_PROVIDER_STARTED_AT_INDEX = %i[source provider started_at].freeze
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
16
|
def current_schema_errors
|
|
@@ -19,25 +19,35 @@ module LlmCostTracker
|
|
|
19
19
|
table_name = LlmCostTracker::ProviderInvoiceImport.table_name
|
|
20
20
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
errors.
|
|
22
|
+
columns = LlmCostTracker::ProviderInvoiceImport.columns_hash
|
|
23
|
+
cache = @schema_capabilities
|
|
24
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
25
|
+
|
|
26
|
+
errors = compute_errors(connection, table_name, columns)
|
|
27
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
25
28
|
errors
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
private
|
|
29
32
|
|
|
30
|
-
def
|
|
31
|
-
|
|
33
|
+
def compute_errors(connection, table_name, columns)
|
|
34
|
+
errors = []
|
|
35
|
+
errors.concat(column_errors(columns))
|
|
36
|
+
errors.concat(index_errors(connection, table_name))
|
|
37
|
+
errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def column_errors(columns)
|
|
41
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
32
42
|
return [] if missing.empty?
|
|
33
43
|
|
|
34
44
|
["missing columns: #{missing.join(', ')}"]
|
|
35
45
|
end
|
|
36
46
|
|
|
37
47
|
def index_errors(connection, table_name)
|
|
38
|
-
return [] if connection.index_exists?(table_name,
|
|
48
|
+
return [] if connection.index_exists?(table_name, SOURCE_PROVIDER_STARTED_AT_INDEX)
|
|
39
49
|
|
|
40
|
-
["missing index: source, started_at"]
|
|
50
|
+
["missing index: source, provider, started_at"]
|
|
41
51
|
end
|
|
42
52
|
end
|
|
43
53
|
end
|
|
@@ -19,27 +19,32 @@ module LlmCostTracker
|
|
|
19
19
|
table_name = LlmCostTracker::ProviderInvoice.table_name
|
|
20
20
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
errors.
|
|
25
|
-
|
|
22
|
+
columns = LlmCostTracker::ProviderInvoice.columns_hash
|
|
23
|
+
cache = @schema_capabilities
|
|
24
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
25
|
+
|
|
26
|
+
errors = compute_errors(connection, table_name, columns)
|
|
27
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
26
28
|
errors
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
private
|
|
30
32
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
+
def compute_errors(connection, table_name, columns)
|
|
34
|
+
errors = []
|
|
35
|
+
errors.concat(column_errors(columns))
|
|
36
|
+
errors.concat(Adapter.json_column_errors(columns["metadata"], connection, "metadata"))
|
|
37
|
+
errors.concat(index_errors(connection, table_name))
|
|
38
|
+
errors
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def column_errors(columns)
|
|
42
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
33
43
|
return [] if missing.empty?
|
|
34
44
|
|
|
35
45
|
["missing columns: #{missing.join(', ')}"]
|
|
36
46
|
end
|
|
37
47
|
|
|
38
|
-
def metadata_type_errors(connection)
|
|
39
|
-
metadata = LlmCostTracker::ProviderInvoice.columns_hash["metadata"]
|
|
40
|
-
Adapter.json_column_errors(metadata, connection, "metadata")
|
|
41
|
-
end
|
|
42
|
-
|
|
43
48
|
def index_errors(connection, table_name)
|
|
44
49
|
errors = []
|
|
45
50
|
unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
|
|
@@ -48,8 +53,18 @@ module LlmCostTracker
|
|
|
48
53
|
unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
|
|
49
54
|
errors << "missing index: source, currency, period_start"
|
|
50
55
|
end
|
|
56
|
+
if Adapter.postgresql?(connection) && !gin_metadata_index?(connection, table_name)
|
|
57
|
+
errors << "missing GIN index on metadata " \
|
|
58
|
+
"(run bin/rails generate llm_cost_tracker:upgrade_provider_invoices_metadata_index)"
|
|
59
|
+
end
|
|
51
60
|
errors
|
|
52
61
|
end
|
|
62
|
+
|
|
63
|
+
def gin_metadata_index?(connection, table_name)
|
|
64
|
+
connection.indexes(table_name).any? do |index|
|
|
65
|
+
index.columns == ["metadata"] && index.using.to_s == "gin"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
53
68
|
end
|
|
54
69
|
end
|
|
55
70
|
end
|
|
@@ -11,27 +11,34 @@ module LlmCostTracker
|
|
|
11
11
|
module Ledger
|
|
12
12
|
class Store
|
|
13
13
|
class << self
|
|
14
|
-
def
|
|
14
|
+
def insert(events, skip_existence_check: false)
|
|
15
15
|
events = Array(events)
|
|
16
|
-
return
|
|
16
|
+
return if events.empty?
|
|
17
17
|
|
|
18
|
-
insertable = insertable_events(events)
|
|
18
|
+
insertable = skip_existence_check ? events : insertable_events(events)
|
|
19
|
+
return unless insertable.any?
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
insert_line_items(insertable, call_ids)
|
|
26
|
-
insert_call_tags(insertable, call_ids)
|
|
27
|
-
end
|
|
28
|
-
increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
|
|
21
|
+
LlmCostTracker::Call.transaction do
|
|
22
|
+
rows = insertable.map { |event| attributes_for(event) }
|
|
23
|
+
call_ids = insert_calls_returning_ids(rows, insertable)
|
|
24
|
+
insert_line_items(insertable, call_ids)
|
|
25
|
+
insert_call_tags(insertable, call_ids)
|
|
29
26
|
end
|
|
30
|
-
|
|
27
|
+
increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
|
|
31
28
|
end
|
|
32
29
|
|
|
33
30
|
private
|
|
34
31
|
|
|
32
|
+
def insert_calls_returning_ids(rows, insertable)
|
|
33
|
+
if LlmCostTracker::Call.connection.supports_insert_returning?
|
|
34
|
+
result = LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: %i[id event_id])
|
|
35
|
+
result.rows.to_h { |id, event_id| [event_id, id] }
|
|
36
|
+
else
|
|
37
|
+
LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
|
|
38
|
+
call_ids_for(insertable)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
def attributes_for(event)
|
|
36
43
|
attributes = {
|
|
37
44
|
event_id: event.event_id,
|
|
@@ -110,7 +117,7 @@ module LlmCostTracker
|
|
|
110
117
|
{
|
|
111
118
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
112
119
|
key: key.to_s,
|
|
113
|
-
value:
|
|
120
|
+
value: Tags::Encoding.encode(value)
|
|
114
121
|
}
|
|
115
122
|
end
|
|
116
123
|
end
|
|
@@ -119,10 +126,6 @@ module LlmCostTracker
|
|
|
119
126
|
LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
|
|
120
127
|
end
|
|
121
128
|
|
|
122
|
-
def tag_row_value(value)
|
|
123
|
-
Tags::Encoding.encode(value)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
129
|
def stored_details(details)
|
|
127
130
|
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
128
131
|
end
|
|
@@ -14,3 +14,16 @@ require_relative "ledger/rollups/upsert_sql"
|
|
|
14
14
|
require_relative "ledger/rollups"
|
|
15
15
|
require_relative "ledger/store"
|
|
16
16
|
require_relative "ledger/period/totals"
|
|
17
|
+
|
|
18
|
+
module LlmCostTracker
|
|
19
|
+
module Ledger
|
|
20
|
+
module Schema
|
|
21
|
+
CORE_SCHEMAS = [
|
|
22
|
+
[Calls, "llm_cost_tracker_calls"],
|
|
23
|
+
[CallLineItems, "llm_cost_tracker_call_line_items"],
|
|
24
|
+
[CallTags, "llm_cost_tracker_call_tags"]
|
|
25
|
+
].freeze
|
|
26
|
+
CACHE_ROLLUPS_SCHEMA = [CallRollups, "llm_cost_tracker_call_rollups"].freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -6,6 +6,7 @@ require "stringio"
|
|
|
6
6
|
require "uri"
|
|
7
7
|
|
|
8
8
|
require_relative "../logging"
|
|
9
|
+
require_relative "../capture/sse"
|
|
9
10
|
require_relative "../capture/stream"
|
|
10
11
|
require_relative "../timing"
|
|
11
12
|
|
|
@@ -18,16 +19,25 @@ module LlmCostTracker
|
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def call(request_env)
|
|
21
|
-
return @app.call(request_env) unless
|
|
22
|
+
return @app.call(request_env) unless enabled?
|
|
22
23
|
|
|
23
24
|
request_url = request_env.url.to_s
|
|
24
25
|
request_body = read_body(request_env.body)
|
|
25
26
|
parser = Parsers.find_for(request_url)
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
request_parsed = parser ? safe_json_parse(request_body) : nil
|
|
28
|
+
streaming = parser&.streaming_request?(request_url, request_parsed)
|
|
29
|
+
if streaming
|
|
30
|
+
request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
|
|
31
|
+
end
|
|
28
32
|
stream_buffer = install_stream_tap(request_env) if streaming
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
if parser
|
|
35
|
+
Tracker.enforce_budget!(
|
|
36
|
+
provider: parser.provider_for(request_url),
|
|
37
|
+
model: parser.model_for(request_url, request_parsed),
|
|
38
|
+
request: request_parsed
|
|
39
|
+
)
|
|
40
|
+
end
|
|
31
41
|
context_tags, metadata = tag_snapshot(request_env) if parser
|
|
32
42
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
33
43
|
|
|
@@ -40,6 +50,26 @@ module LlmCostTracker
|
|
|
40
50
|
|
|
41
51
|
private
|
|
42
52
|
|
|
53
|
+
def enabled?
|
|
54
|
+
return @enabled if defined?(@enabled)
|
|
55
|
+
|
|
56
|
+
@enabled = LlmCostTracker.configuration.enabled
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def safe_json_parse(body)
|
|
60
|
+
return {} if body.nil? || body.empty?
|
|
61
|
+
|
|
62
|
+
JSON.parse(body)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
{}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def auto_enable_stream_usage?
|
|
68
|
+
return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
|
|
69
|
+
|
|
70
|
+
@auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
|
|
71
|
+
end
|
|
72
|
+
|
|
43
73
|
def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
|
|
44
74
|
stream_buffer:, context_tags:, metadata:, started_at:)
|
|
45
75
|
response_received = false
|
|
@@ -63,16 +93,15 @@ module LlmCostTracker
|
|
|
63
93
|
raise
|
|
64
94
|
end
|
|
65
95
|
|
|
66
|
-
def inject_stream_usage_flag(request_env, parser, request_url)
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
return body_string unless parser&.auto_enable_stream_usage?(request_url)
|
|
96
|
+
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
97
|
+
return nil unless auto_enable_stream_usage?
|
|
98
|
+
return nil unless parser&.auto_enable_stream_usage?(request_url)
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
return
|
|
100
|
+
stream_options = request_parsed["stream_options"]
|
|
101
|
+
return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
|
|
73
102
|
|
|
74
|
-
|
|
75
|
-
new_body =
|
|
103
|
+
request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
|
|
104
|
+
new_body = request_parsed.to_json
|
|
76
105
|
request_env.body = new_body
|
|
77
106
|
new_body
|
|
78
107
|
end
|
|
@@ -80,9 +109,9 @@ module LlmCostTracker
|
|
|
80
109
|
def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
|
|
81
110
|
context_tags:, metadata:, error:)
|
|
82
111
|
request = parser.safe_json_parse(request_body)
|
|
83
|
-
|
|
112
|
+
event = Event.build(
|
|
84
113
|
provider: parser.provider_for(request_url),
|
|
85
|
-
model: request["model"] ||
|
|
114
|
+
model: request["model"] || Event::UNKNOWN_MODEL,
|
|
86
115
|
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
87
116
|
stream: true,
|
|
88
117
|
usage_source: :unknown
|
|
@@ -92,7 +121,7 @@ module LlmCostTracker
|
|
|
92
121
|
stream_interrupted_error: "#{error.class}: #{error.message}"
|
|
93
122
|
)
|
|
94
123
|
Tracker.record(
|
|
95
|
-
|
|
124
|
+
event: event,
|
|
96
125
|
latency_ms: latency_ms,
|
|
97
126
|
metadata: merged_metadata,
|
|
98
127
|
context_tags: context_tags
|
|
@@ -125,7 +154,7 @@ module LlmCostTracker
|
|
|
125
154
|
return unless parsed
|
|
126
155
|
|
|
127
156
|
Tracker.record(
|
|
128
|
-
|
|
157
|
+
event: parsed,
|
|
129
158
|
latency_ms: latency_ms,
|
|
130
159
|
metadata: metadata,
|
|
131
160
|
context_tags: context_tags
|
|
@@ -173,7 +202,7 @@ module LlmCostTracker
|
|
|
173
202
|
)
|
|
174
203
|
end
|
|
175
204
|
|
|
176
|
-
events = overflowed ? [] :
|
|
205
|
+
events = overflowed ? [] : Capture::SSE.parse(body)
|
|
177
206
|
parser.parse_stream(
|
|
178
207
|
request_url: request_url,
|
|
179
208
|
request_body: request_body,
|