llm_cost_tracker 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +12 -5
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
- data/lib/llm_cost_tracker/budget.rb +4 -2
- data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +53 -1
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -3
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/event.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
- data/lib/llm_cost_tracker/ingestion.rb +48 -10
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
- data/lib/llm_cost_tracker/integrations/base.rb +22 -5
- data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
- data/lib/llm_cost_tracker/integrations.rb +19 -1
- data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
- data/lib/llm_cost_tracker/ledger/store.rb +14 -14
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
- data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
- data/lib/llm_cost_tracker/parsers/base.rb +5 -1
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
- data/lib/llm_cost_tracker/prices.json +110 -19
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
- data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
- data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
- data/lib/llm_cost_tracker/pricing.rb +47 -19
- data/lib/llm_cost_tracker/railtie.rb +6 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/retention.rb +15 -2
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
- data/lib/llm_cost_tracker/token_usage.rb +10 -2
- data/lib/llm_cost_tracker/tracker.rb +45 -18
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +9 -0
- data/lib/tasks/llm_cost_tracker.rake +25 -2
- metadata +36 -1
|
@@ -38,16 +38,32 @@ module LlmCostTracker
|
|
|
38
38
|
|
|
39
39
|
def snapshot_select(period)
|
|
40
40
|
start = Period.range_start(period, time)
|
|
41
|
+
components = [period_total_sql(period, start)]
|
|
42
|
+
components << pending_total_sql(start) if Ingestion.durable?
|
|
41
43
|
"SELECT #{connection.quote(period.name)} AS period_key, " \
|
|
42
|
-
"(#{
|
|
44
|
+
"(#{components.join(') + (')}) AS total_cost"
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
def
|
|
47
|
+
def period_total_sql(period, start)
|
|
48
|
+
if LlmCostTracker.configuration.cache_rollups
|
|
49
|
+
"GREATEST(COALESCE(#{rollup_sum_sql(period)}, 0), COALESCE(#{calls_sum_sql(start)}, 0))"
|
|
50
|
+
else
|
|
51
|
+
"COALESCE(#{calls_sum_sql(start)}, 0)"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rollup_sum_sql(period)
|
|
46
56
|
table = connection.quote_table_name("llm_cost_tracker_call_rollups")
|
|
47
|
-
"
|
|
57
|
+
"(SELECT SUM(total_cost) FROM #{table} " \
|
|
48
58
|
"WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
|
|
49
|
-
"AND period_start = #{connection.quote(Period.bucket(period, time))}
|
|
50
|
-
|
|
59
|
+
"AND period_start = #{connection.quote(Period.bucket(period, time))})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def calls_sum_sql(start)
|
|
63
|
+
table = connection.quote_table_name("llm_cost_tracker_calls")
|
|
64
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
65
|
+
"(SELECT SUM(total_cost) FROM #{table} " \
|
|
66
|
+
"WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)})"
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
def pending_total_sql(start)
|
|
@@ -35,22 +35,25 @@ module LlmCostTracker
|
|
|
35
35
|
|
|
36
36
|
def period_rows(event)
|
|
37
37
|
currency = currency_for(event)
|
|
38
|
+
provider = provider_for(event)
|
|
38
39
|
Period::PERIODS.map do |period, name|
|
|
39
40
|
{
|
|
40
41
|
period: name,
|
|
41
42
|
period_start: Period.bucket(period, event.tracked_at),
|
|
42
43
|
currency: currency,
|
|
44
|
+
provider: provider,
|
|
43
45
|
total_cost: event.total_cost
|
|
44
46
|
}
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def period_rows_for_events(events)
|
|
49
|
-
call_rollups(events).map do |(period, period_start, currency), total_cost|
|
|
51
|
+
call_rollups(events).map do |(period, period_start, currency, provider), total_cost|
|
|
50
52
|
{
|
|
51
53
|
period: period,
|
|
52
54
|
period_start: period_start,
|
|
53
55
|
currency: currency,
|
|
56
|
+
provider: provider,
|
|
54
57
|
total_cost: total_cost
|
|
55
58
|
}
|
|
56
59
|
end
|
|
@@ -59,29 +62,33 @@ module LlmCostTracker
|
|
|
59
62
|
def call_rollups(events)
|
|
60
63
|
events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
|
|
61
64
|
currency = currency_for(event)
|
|
65
|
+
provider = provider_for(event)
|
|
62
66
|
Period::PERIODS.each do |period, name|
|
|
63
|
-
|
|
67
|
+
key = [name, Period.bucket(period, event.tracked_at), currency, provider]
|
|
68
|
+
totals[key] += BigDecimal(event.total_cost.to_s)
|
|
64
69
|
end
|
|
65
70
|
end
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
def period_decrement_totals(call_rows)
|
|
69
74
|
call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
|
|
70
|
-
_id, tracked_at, total_cost, pricing_snapshot = row
|
|
75
|
+
_id, tracked_at, total_cost, pricing_snapshot, provider = row
|
|
71
76
|
next unless total_cost
|
|
72
77
|
|
|
73
78
|
currency = currency_from_snapshot(pricing_snapshot)
|
|
79
|
+
provider_key = provider.to_s
|
|
74
80
|
Period::PERIODS.each_key do |period|
|
|
75
|
-
totals[[period, Period.bucket(period, tracked_at), currency]] += total_cost
|
|
81
|
+
totals[[period, Period.bucket(period, tracked_at), currency, provider_key]] += total_cost
|
|
76
82
|
end
|
|
77
83
|
end
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
def apply_decrements(totals)
|
|
81
87
|
now = Time.now.utc
|
|
82
|
-
buckets_by_period = totals.each_with_object({}) do |(
|
|
83
|
-
|
|
84
|
-
grouped[[period, currency]]
|
|
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
|
|
85
92
|
end
|
|
86
93
|
|
|
87
94
|
conn = LlmCostTracker::CallRollup.connection
|
|
@@ -89,10 +96,11 @@ module LlmCostTracker
|
|
|
89
96
|
period_col = conn.quote_column_name("period")
|
|
90
97
|
start_col = conn.quote_column_name("period_start")
|
|
91
98
|
currency_col = conn.quote_column_name("currency")
|
|
99
|
+
provider_col = conn.quote_column_name("provider")
|
|
92
100
|
total_col = conn.quote_column_name("total_cost")
|
|
93
101
|
updated_col = conn.quote_column_name("updated_at")
|
|
94
102
|
|
|
95
|
-
buckets_by_period.each do |(period, currency), by_start|
|
|
103
|
+
buckets_by_period.each do |(period, currency, provider), by_start|
|
|
96
104
|
case_clauses = by_start.map do |period_start, amount|
|
|
97
105
|
"WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
|
|
98
106
|
end.join(" ")
|
|
@@ -104,6 +112,7 @@ module LlmCostTracker
|
|
|
104
112
|
"#{updated_col} = #{conn.quote(now)} " \
|
|
105
113
|
"WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
|
|
106
114
|
"AND #{currency_col} = #{conn.quote(currency)} " \
|
|
115
|
+
"AND #{provider_col} = #{conn.quote(provider)} " \
|
|
107
116
|
"AND #{start_col} IN (#{starts})"
|
|
108
117
|
)
|
|
109
118
|
end
|
|
@@ -115,7 +124,12 @@ module LlmCostTracker
|
|
|
115
124
|
end
|
|
116
125
|
|
|
117
126
|
def currency_from_snapshot(snapshot)
|
|
118
|
-
(snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
|
|
127
|
+
value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
|
|
128
|
+
value.to_s.upcase
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def provider_for(event)
|
|
132
|
+
(event.respond_to?(:provider) ? event.provider : nil).to_s
|
|
119
133
|
end
|
|
120
134
|
|
|
121
135
|
def upsert_call_rollups(rows)
|
|
@@ -130,7 +144,7 @@ module LlmCostTracker
|
|
|
130
144
|
def call_rollups_unique_by
|
|
131
145
|
return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
|
|
132
146
|
|
|
133
|
-
%i[period period_start currency]
|
|
147
|
+
%i[period period_start currency provider]
|
|
134
148
|
end
|
|
135
149
|
end
|
|
136
150
|
end
|
|
@@ -27,6 +27,11 @@ module LlmCostTracker
|
|
|
27
27
|
provider_field
|
|
28
28
|
provider_item_id
|
|
29
29
|
details
|
|
30
|
+
created_at
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
REQUIRED_INDEX_COLUMNS = [
|
|
34
|
+
%w[llm_cost_tracker_call_id position]
|
|
30
35
|
].freeze
|
|
31
36
|
|
|
32
37
|
class << self
|
|
@@ -41,7 +46,31 @@ module LlmCostTracker
|
|
|
41
46
|
missing = REQUIRED_COLUMNS - columns.keys
|
|
42
47
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
43
48
|
errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
|
|
44
|
-
errors
|
|
49
|
+
errors.concat(missing_index_errors(connection, table_name))
|
|
50
|
+
errors << missing_fk_error(connection, table_name) if missing_fk?(connection, table_name)
|
|
51
|
+
errors.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def missing_index_errors(connection, table_name)
|
|
55
|
+
existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
|
|
56
|
+
REQUIRED_INDEX_COLUMNS.filter_map do |required|
|
|
57
|
+
next if existing.any? { |columns| columns == required }
|
|
58
|
+
|
|
59
|
+
"missing index on (#{required.join(', ')})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def missing_fk?(connection, table_name)
|
|
64
|
+
connection.foreign_keys(table_name).none? do |fk|
|
|
65
|
+
fk.column.to_s == "llm_cost_tracker_call_id" &&
|
|
66
|
+
fk.to_table.to_s == "llm_cost_tracker_calls"
|
|
67
|
+
end
|
|
68
|
+
rescue NotImplementedError, NoMethodError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def missing_fk_error(_connection, _table_name)
|
|
73
|
+
"missing foreign key on llm_cost_tracker_call_id referencing llm_cost_tracker_calls"
|
|
45
74
|
end
|
|
46
75
|
end
|
|
47
76
|
end
|
|
@@ -6,8 +6,8 @@ module LlmCostTracker
|
|
|
6
6
|
module Ledger
|
|
7
7
|
module Schema
|
|
8
8
|
module CallRollups
|
|
9
|
-
REQUIRED_COLUMNS = %w[period period_start currency total_cost].freeze
|
|
10
|
-
UNIQUE_COLUMNS = %i[period period_start currency].freeze
|
|
9
|
+
REQUIRED_COLUMNS = %w[period period_start currency provider total_cost created_at updated_at].freeze
|
|
10
|
+
UNIQUE_COLUMNS = %i[period period_start currency provider].freeze
|
|
11
11
|
|
|
12
12
|
class << self
|
|
13
13
|
def current_schema_errors
|
|
@@ -20,7 +20,7 @@ module LlmCostTracker
|
|
|
20
20
|
missing = REQUIRED_COLUMNS - LlmCostTracker::CallRollup.columns_hash.keys
|
|
21
21
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
22
22
|
unless unique_period_index?(connection, table_name)
|
|
23
|
-
errors << "missing unique index: period, period_start, currency"
|
|
23
|
+
errors << "missing unique index: period, period_start, currency, provider"
|
|
24
24
|
end
|
|
25
25
|
errors
|
|
26
26
|
end
|
|
@@ -6,6 +6,11 @@ module LlmCostTracker
|
|
|
6
6
|
module CallTags
|
|
7
7
|
REQUIRED_COLUMNS = %w[llm_cost_tracker_call_id key value].freeze
|
|
8
8
|
|
|
9
|
+
REQUIRED_INDEX_COLUMNS = [
|
|
10
|
+
%w[key value],
|
|
11
|
+
%w[llm_cost_tracker_call_id]
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
9
14
|
class << self
|
|
10
15
|
def current_schema_errors
|
|
11
16
|
connection = LlmCostTracker::Call.connection
|
|
@@ -14,10 +19,20 @@ module LlmCostTracker
|
|
|
14
19
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
15
20
|
|
|
16
21
|
columns = LlmCostTracker::CallTag.columns_hash
|
|
22
|
+
errors = []
|
|
17
23
|
missing = REQUIRED_COLUMNS - columns.keys
|
|
18
|
-
|
|
24
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
25
|
+
errors.concat(missing_index_errors(connection, table_name))
|
|
26
|
+
errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def missing_index_errors(connection, table_name)
|
|
30
|
+
existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
|
|
31
|
+
REQUIRED_INDEX_COLUMNS.filter_map do |required|
|
|
32
|
+
next if existing.any? { |columns| (required - columns).empty? }
|
|
19
33
|
|
|
20
|
-
|
|
34
|
+
"missing index on (#{required.join(', ')})"
|
|
35
|
+
end
|
|
21
36
|
end
|
|
22
37
|
end
|
|
23
38
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module IngestionInboxEntries
|
|
9
|
+
REQUIRED_COLUMNS = %w[
|
|
10
|
+
event_id
|
|
11
|
+
total_cost
|
|
12
|
+
tracked_at
|
|
13
|
+
payload
|
|
14
|
+
locked_at
|
|
15
|
+
locked_by
|
|
16
|
+
attempts
|
|
17
|
+
last_error
|
|
18
|
+
created_at
|
|
19
|
+
updated_at
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
UNIQUE_COLUMNS = %i[event_id].freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def current_schema_errors
|
|
26
|
+
connection = LlmCostTracker::Ingestion::InboxEntry.connection
|
|
27
|
+
Adapter.ensure_supported!(connection)
|
|
28
|
+
table_name = LlmCostTracker::Ingestion::InboxEntry.table_name
|
|
29
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
30
|
+
|
|
31
|
+
errors = []
|
|
32
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::InboxEntry.columns_hash.keys
|
|
33
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
34
|
+
errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
|
|
35
|
+
errors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def event_id_unique_index?(connection, table_name)
|
|
41
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module IngestionLeases
|
|
9
|
+
REQUIRED_COLUMNS = %w[
|
|
10
|
+
name
|
|
11
|
+
locked_by
|
|
12
|
+
locked_until
|
|
13
|
+
created_at
|
|
14
|
+
updated_at
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
UNIQUE_COLUMNS = %i[name].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def current_schema_errors
|
|
21
|
+
connection = LlmCostTracker::Ingestion::Lease.connection
|
|
22
|
+
Adapter.ensure_supported!(connection)
|
|
23
|
+
table_name = LlmCostTracker::Ingestion::Lease.table_name
|
|
24
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
25
|
+
|
|
26
|
+
errors = []
|
|
27
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::Lease.columns_hash.keys
|
|
28
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
29
|
+
errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
|
|
30
|
+
errors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def name_unique_index?(connection, table_name)
|
|
36
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module ProviderInvoiceImports
|
|
9
|
+
REQUIRED_COLUMNS = %w[
|
|
10
|
+
source cursor window_start window_end state last_error
|
|
11
|
+
rows_imported started_at finished_at
|
|
12
|
+
].freeze
|
|
13
|
+
SOURCE_STARTED_AT_INDEX = %i[source started_at].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def current_schema_errors
|
|
17
|
+
connection = LlmCostTracker::Call.connection
|
|
18
|
+
Adapter.ensure_supported!(connection)
|
|
19
|
+
table_name = LlmCostTracker::ProviderInvoiceImport.table_name
|
|
20
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
21
|
+
|
|
22
|
+
errors = []
|
|
23
|
+
errors.concat(column_errors)
|
|
24
|
+
errors.concat(index_errors(connection, table_name))
|
|
25
|
+
errors
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def column_errors
|
|
31
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoiceImport.columns_hash.keys
|
|
32
|
+
return [] if missing.empty?
|
|
33
|
+
|
|
34
|
+
["missing columns: #{missing.join(', ')}"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def index_errors(connection, table_name)
|
|
38
|
+
return [] if connection.index_exists?(table_name, SOURCE_STARTED_AT_INDEX)
|
|
39
|
+
|
|
40
|
+
["missing index: source, started_at"]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -10,7 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
source period_start period_end external_id billed_amount currency metadata imported_at
|
|
11
11
|
].freeze
|
|
12
12
|
UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
|
|
13
|
-
SOURCE_PERIOD_INDEX_COLUMNS = %i[source period_start].freeze
|
|
13
|
+
SOURCE_PERIOD_INDEX_COLUMNS = %i[source currency period_start].freeze
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
16
|
def current_schema_errors
|
|
@@ -46,7 +46,7 @@ module LlmCostTracker
|
|
|
46
46
|
errors << "missing unique index: external_id"
|
|
47
47
|
end
|
|
48
48
|
unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
|
|
49
|
-
errors << "missing index: source, period_start"
|
|
49
|
+
errors << "missing index: source, currency, period_start"
|
|
50
50
|
end
|
|
51
51
|
errors
|
|
52
52
|
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require_relative "../pricing"
|
|
6
6
|
require_relative "../billing/line_item"
|
|
7
7
|
require_relative "rollups"
|
|
8
|
+
require_relative "tags/encoding"
|
|
8
9
|
|
|
9
10
|
module LlmCostTracker
|
|
10
11
|
module Ledger
|
|
@@ -23,8 +24,8 @@ module LlmCostTracker
|
|
|
23
24
|
call_ids = call_ids_for(insertable)
|
|
24
25
|
insert_line_items(insertable, call_ids)
|
|
25
26
|
insert_call_tags(insertable, call_ids)
|
|
26
|
-
Ledger::Rollups.increment_many!(insertable)
|
|
27
27
|
end
|
|
28
|
+
increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
|
|
28
29
|
end
|
|
29
30
|
events
|
|
30
31
|
end
|
|
@@ -119,14 +120,21 @@ module LlmCostTracker
|
|
|
119
120
|
end
|
|
120
121
|
|
|
121
122
|
def tag_row_value(value)
|
|
122
|
-
|
|
123
|
-
when Hash, Array then JSON.generate(stored_tag_value(value))
|
|
124
|
-
else value.to_s
|
|
125
|
-
end
|
|
123
|
+
Tags::Encoding.encode(value)
|
|
126
124
|
end
|
|
127
125
|
|
|
128
126
|
def stored_details(details)
|
|
129
|
-
(details || {}).transform_keys(&:to_s).transform_values { |value|
|
|
127
|
+
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def increment_rollups_safely(events)
|
|
131
|
+
Ledger::Rollups.increment_many!(events)
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
raise if LlmCostTracker::Call.connection.open_transactions.positive?
|
|
134
|
+
|
|
135
|
+
LlmCostTracker::Logging.warn(
|
|
136
|
+
"Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
|
|
137
|
+
)
|
|
130
138
|
end
|
|
131
139
|
|
|
132
140
|
def insertable_events(events)
|
|
@@ -138,14 +146,6 @@ module LlmCostTracker
|
|
|
138
146
|
!existing_ids.include?(event_id) && seen_ids.add?(event_id)
|
|
139
147
|
end
|
|
140
148
|
end
|
|
141
|
-
|
|
142
|
-
def stored_tag_value(value)
|
|
143
|
-
if value.is_a?(Hash)
|
|
144
|
-
return value.transform_keys(&:to_s).transform_values { |nested| stored_tag_value(nested) }
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
value.to_s
|
|
148
|
-
end
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Tags
|
|
8
|
+
module Encoding
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def encode(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash then JSON.generate(normalize_hash(value))
|
|
14
|
+
when Array then JSON.generate(normalize_array(value))
|
|
15
|
+
else value.to_s
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalize_hash(hash)
|
|
20
|
+
hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def normalize_array(array)
|
|
24
|
+
array.map { |v| normalize_value(v) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize_value(value)
|
|
28
|
+
case value
|
|
29
|
+
when Hash then normalize_hash(value)
|
|
30
|
+
when Array then normalize_array(value)
|
|
31
|
+
else value.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../schema/adapter"
|
|
4
|
+
require_relative "encoding"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Ledger
|
|
@@ -8,7 +9,7 @@ module LlmCostTracker
|
|
|
8
9
|
module Query
|
|
9
10
|
class << self
|
|
10
11
|
def apply(tags)
|
|
11
|
-
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(
|
|
12
|
+
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
|
|
12
13
|
return LlmCostTracker::Call.all if normalized_tags.empty?
|
|
13
14
|
|
|
14
15
|
normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
|
|
@@ -5,7 +5,8 @@ require_relative "ledger/schema/calls"
|
|
|
5
5
|
require_relative "ledger/schema/call_rollups"
|
|
6
6
|
require_relative "ledger/schema/call_line_items"
|
|
7
7
|
require_relative "ledger/schema/call_tags"
|
|
8
|
-
require_relative "ledger/schema/
|
|
8
|
+
require_relative "ledger/schema/ingestion_inbox_entries"
|
|
9
|
+
require_relative "ledger/schema/ingestion_leases"
|
|
9
10
|
require_relative "ledger/tags/query"
|
|
10
11
|
require_relative "ledger/tags/sql"
|
|
11
12
|
require_relative "ledger/period"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Masking
|
|
5
|
+
SENSITIVE_KEYS = %i[
|
|
6
|
+
provider_api_key_id provider_workspace_id provider_organization_id provider_project_id
|
|
7
|
+
].to_set.freeze
|
|
8
|
+
MASK_TAIL_LENGTH = 4
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def mask_value(key, value)
|
|
13
|
+
string = value.to_s
|
|
14
|
+
return string unless SENSITIVE_KEYS.include?(key.to_sym)
|
|
15
|
+
return string if string.length <= MASK_TAIL_LENGTH
|
|
16
|
+
|
|
17
|
+
"***#{string[-MASK_TAIL_LENGTH, MASK_TAIL_LENGTH]}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def format_attribution(attribution, separator: ", ")
|
|
21
|
+
return "" if attribution.nil? || attribution.empty?
|
|
22
|
+
|
|
23
|
+
attribution.map { |key, value| "#{key}=#{mask_value(key, value)}" }.join(separator)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mask_hash(hash)
|
|
27
|
+
return hash unless hash.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
hash.each_with_object({}) do |(key, value), masked|
|
|
30
|
+
masked[key] = case value
|
|
31
|
+
when Hash then mask_hash(value)
|
|
32
|
+
when Array then value.map { |entry| entry.is_a?(Hash) ? mask_hash(entry) : entry }
|
|
33
|
+
else
|
|
34
|
+
mask_value(key, value)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|