llm_cost_tracker 0.5.3 → 0.6.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 +30 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
- data/docs/architecture.md +1 -1
- data/docs/configuration.md +1 -1
- data/docs/technical/data-flow.md +8 -5
- data/docs/technical/operational-notes.md +21 -1
- data/docs/upgrading.md +1 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
- data/lib/llm_cost_tracker/inbox_event.rb +9 -0
- data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
- data/lib/llm_cost_tracker/period_grouping.rb +4 -3
- data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
- data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
- data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
- data/lib/llm_cost_tracker/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +3 -3
- data/lib/llm_cost_tracker/tags_column.rb +7 -1
- data/lib/llm_cost_tracker/tracker.rb +3 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -1
- metadata +17 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ingestor_lease"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
class ActiveRecordIngestorLease
|
|
8
|
+
LEASE_NAME = "default"
|
|
9
|
+
|
|
10
|
+
def initialize(identity:, seconds:)
|
|
11
|
+
@identity = identity
|
|
12
|
+
@seconds = seconds
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def acquire
|
|
16
|
+
now = Time.now.utc
|
|
17
|
+
LlmCostTracker::IngestorLease.transaction do
|
|
18
|
+
lease = LlmCostTracker::IngestorLease.lock.find_by(name: LEASE_NAME)
|
|
19
|
+
lease ||= LlmCostTracker::IngestorLease.create!(name: LEASE_NAME)
|
|
20
|
+
next false unless available?(lease, now)
|
|
21
|
+
|
|
22
|
+
lease.update!(locked_by: identity, locked_until: now + seconds)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :identity, :seconds
|
|
32
|
+
|
|
33
|
+
def available?(lease, now)
|
|
34
|
+
lease.locked_by.nil? || lease.locked_by == identity || lease.locked_until.nil? || lease.locked_until < now
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "active_record_inbox"
|
|
4
|
+
require_relative "active_record_periods"
|
|
5
|
+
require_relative "active_record_rollups"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Storage
|
|
9
|
+
class ActiveRecordPeriodTotals
|
|
10
|
+
def self.call(periods, time:)
|
|
11
|
+
new(periods, time: time).totals
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(periods, time:)
|
|
15
|
+
@periods = ActiveRecordPeriods.valid_keys(periods)
|
|
16
|
+
@time = time
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def totals
|
|
20
|
+
return {} if periods.empty?
|
|
21
|
+
return ActiveRecordRollups.period_totals(periods, time: time) unless ActiveRecordInbox.enabled?
|
|
22
|
+
|
|
23
|
+
snapshot_totals
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :periods, :time
|
|
29
|
+
|
|
30
|
+
def snapshot_totals
|
|
31
|
+
values = periods.to_h { |period| [period, 0.0] }
|
|
32
|
+
connection.select_all(snapshot_sql).each do |row|
|
|
33
|
+
values[row.fetch("period_key").to_sym] = row.fetch("total_cost").to_f
|
|
34
|
+
end
|
|
35
|
+
values
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def snapshot_sql
|
|
39
|
+
periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def snapshot_select(period)
|
|
43
|
+
start = range_start_for(period)
|
|
44
|
+
"SELECT #{connection.quote(period.to_s)} AS period_key, " \
|
|
45
|
+
"(#{stored_total_sql(period, start)}) + (#{pending_total_sql(start)}) AS total_cost"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stored_total_sql(period, start)
|
|
49
|
+
period_totals_table? ? rollup_total_sql(period) : ledger_total_sql(start)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rollup_total_sql(period)
|
|
53
|
+
table = connection.quote_table_name("llm_cost_tracker_period_totals")
|
|
54
|
+
"COALESCE((SELECT total_cost FROM #{table} " \
|
|
55
|
+
"WHERE period = #{connection.quote(ActiveRecordPeriods::PERIODS.fetch(period))} " \
|
|
56
|
+
"AND period_start = #{connection.quote(ActiveRecordPeriods.bucket(period, time))} LIMIT 1), 0)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ledger_total_sql(start)
|
|
60
|
+
table = LlmCostTracker::LlmApiCall.quoted_table_name
|
|
61
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
62
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
63
|
+
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
64
|
+
"WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pending_total_sql(start)
|
|
68
|
+
table = connection.quote_table_name(ActiveRecordInbox::TABLE_NAME)
|
|
69
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
70
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
71
|
+
attempts = connection.quote_column_name("attempts")
|
|
72
|
+
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
73
|
+
"WHERE #{attempts} < #{ActiveRecordInbox::MAX_ATTEMPTS} " \
|
|
74
|
+
"AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def period_totals_table? = connection.data_source_exists?("llm_cost_tracker_period_totals")
|
|
78
|
+
|
|
79
|
+
def range_start_for(period) = ActiveRecordPeriods.range_start(period, time)
|
|
80
|
+
|
|
81
|
+
def connection = LlmCostTracker::LlmApiCall.connection
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Storage
|
|
5
|
+
module ActiveRecordPeriods
|
|
6
|
+
PERIODS = {
|
|
7
|
+
monthly: "month",
|
|
8
|
+
daily: "day"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def valid_keys(periods)
|
|
14
|
+
periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def range_start(period, time)
|
|
18
|
+
utc_time = time.to_time.utc
|
|
19
|
+
|
|
20
|
+
case period
|
|
21
|
+
when :monthly then utc_time.beginning_of_month
|
|
22
|
+
when :daily then utc_time.beginning_of_day
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def bucket(period, time)
|
|
27
|
+
range_start(period, time).to_date
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "active_record_periods"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Storage
|
|
9
|
+
class ActiveRecordRollupBatch
|
|
10
|
+
def self.rows(events)
|
|
11
|
+
new(events).rows
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(events)
|
|
15
|
+
@events = events
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rows
|
|
19
|
+
totals.map do |(period, period_start), total_cost|
|
|
20
|
+
{
|
|
21
|
+
period: period,
|
|
22
|
+
period_start: period_start,
|
|
23
|
+
total_cost: total_cost
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :events
|
|
31
|
+
|
|
32
|
+
def totals
|
|
33
|
+
events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
|
|
34
|
+
ActiveRecordPeriods::PERIODS.each do |period, name|
|
|
35
|
+
rows[[name, ActiveRecordPeriods.bucket(period, event.tracked_at)]] += BigDecimal(event.cost.total_cost.to_s)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../active_record_adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
class ActiveRecordRollupUpsertSql
|
|
8
|
+
def self.call(model)
|
|
9
|
+
new(model).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(model)
|
|
13
|
+
@model = model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return Arel.sql(mysql_sql) if ActiveRecordAdapter.mysql?(connection)
|
|
18
|
+
return Arel.sql(postgres_sql) if ActiveRecordAdapter.postgresql?(connection)
|
|
19
|
+
|
|
20
|
+
Arel.sql("total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :model
|
|
26
|
+
|
|
27
|
+
def postgres_sql
|
|
28
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
29
|
+
updated_at = connection.quote_column_name("updated_at")
|
|
30
|
+
|
|
31
|
+
"#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
32
|
+
"#{updated_at} = excluded.#{updated_at}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mysql_sql
|
|
36
|
+
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def connection = model.connection
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
|
|
5
|
+
require_relative "active_record_periods"
|
|
6
|
+
require_relative "active_record_rollup_batch"
|
|
7
|
+
require_relative "active_record_rollup_upsert_sql"
|
|
8
|
+
|
|
5
9
|
module LlmCostTracker
|
|
6
10
|
module Storage
|
|
7
11
|
class ActiveRecordRollups
|
|
8
|
-
PERIODS = {
|
|
9
|
-
monthly: "month",
|
|
10
|
-
daily: "day"
|
|
11
|
-
}.freeze
|
|
12
|
-
|
|
13
12
|
class << self
|
|
14
13
|
def reset!
|
|
15
14
|
remove_instance_variable(:@period_totals_enabled) if instance_variable_defined?(:@period_totals_enabled)
|
|
@@ -22,7 +21,21 @@ module LlmCostTracker
|
|
|
22
21
|
model = period_total_model
|
|
23
22
|
model.upsert_all(
|
|
24
23
|
period_rows(event),
|
|
25
|
-
on_duplicate:
|
|
24
|
+
on_duplicate: ActiveRecordRollupUpsertSql.call(model),
|
|
25
|
+
record_timestamps: true,
|
|
26
|
+
unique_by: unique_by(model, %i[period period_start])
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def increment_many!(events)
|
|
31
|
+
events = Array(events).select { |event| event.cost&.total_cost }
|
|
32
|
+
return if events.empty?
|
|
33
|
+
return unless period_totals_enabled?
|
|
34
|
+
|
|
35
|
+
model = period_total_model
|
|
36
|
+
model.upsert_all(
|
|
37
|
+
ActiveRecordRollupBatch.rows(events),
|
|
38
|
+
on_duplicate: ActiveRecordRollupUpsertSql.call(model),
|
|
26
39
|
record_timestamps: true,
|
|
27
40
|
unique_by: unique_by(model, %i[period period_start])
|
|
28
41
|
)
|
|
@@ -37,16 +50,8 @@ module LlmCostTracker
|
|
|
37
50
|
apply_decrements(totals)
|
|
38
51
|
end
|
|
39
52
|
|
|
40
|
-
def monthly_total(time: Time.now.utc)
|
|
41
|
-
period_totals(%i[monthly], time: time).fetch(:monthly)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def daily_total(time: Time.now.utc)
|
|
45
|
-
period_totals(%i[daily], time: time).fetch(:daily)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
53
|
def period_totals(periods, time: Time.now.utc)
|
|
49
|
-
periods =
|
|
54
|
+
periods = ActiveRecordPeriods.valid_keys(periods)
|
|
50
55
|
return {} if periods.empty?
|
|
51
56
|
|
|
52
57
|
if period_totals_enabled?
|
|
@@ -59,10 +64,10 @@ module LlmCostTracker
|
|
|
59
64
|
private
|
|
60
65
|
|
|
61
66
|
def period_rows(event)
|
|
62
|
-
PERIODS.map do |period, name|
|
|
67
|
+
ActiveRecordPeriods::PERIODS.map do |period, name|
|
|
63
68
|
{
|
|
64
69
|
period: name,
|
|
65
|
-
period_start:
|
|
70
|
+
period_start: ActiveRecordPeriods.bucket(period, event.tracked_at),
|
|
66
71
|
total_cost: event.cost.total_cost
|
|
67
72
|
}
|
|
68
73
|
end
|
|
@@ -73,8 +78,8 @@ module LlmCostTracker
|
|
|
73
78
|
_id, tracked_at, total_cost = row
|
|
74
79
|
next unless total_cost
|
|
75
80
|
|
|
76
|
-
PERIODS.each_key do |period|
|
|
77
|
-
totals[[period,
|
|
81
|
+
ActiveRecordPeriods::PERIODS.each_key do |period|
|
|
82
|
+
totals[[period, ActiveRecordPeriods.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
end
|
|
@@ -84,28 +89,23 @@ module LlmCostTracker
|
|
|
84
89
|
now = Time.now.utc
|
|
85
90
|
|
|
86
91
|
totals.each do |(period, period_start), amount|
|
|
87
|
-
row = model.lock.find_by(period: PERIODS.fetch(period), period_start: period_start)
|
|
92
|
+
row = model.lock.find_by(period: ActiveRecordPeriods::PERIODS.fetch(period), period_start: period_start)
|
|
88
93
|
next unless row
|
|
89
94
|
|
|
90
95
|
row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
|
|
91
96
|
end
|
|
92
97
|
end
|
|
93
98
|
|
|
94
|
-
def decremented_total(current, amount)
|
|
95
|
-
[decimal(current) - amount, BigDecimal("0")].max
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def decimal(value)
|
|
99
|
-
BigDecimal(value.to_s)
|
|
100
|
-
end
|
|
99
|
+
def decremented_total(current, amount) = [BigDecimal(current.to_s) - amount, BigDecimal("0")].max
|
|
101
100
|
|
|
102
101
|
def rollup_period_totals(periods, time)
|
|
103
|
-
buckets = periods.to_h { |period| [period,
|
|
104
|
-
index = buckets.to_h { |period, bucket| [[PERIODS.fetch(period), bucket], period] }
|
|
102
|
+
buckets = periods.to_h { |period| [period, ActiveRecordPeriods.bucket(period, time)] }
|
|
103
|
+
index = buckets.to_h { |period, bucket| [[ActiveRecordPeriods::PERIODS.fetch(period), bucket], period] }
|
|
105
104
|
totals = periods.to_h { |period| [period, 0.0] }
|
|
106
105
|
|
|
107
106
|
period_total_model
|
|
108
|
-
.where(period: periods.map { |period| PERIODS.fetch(period) },
|
|
107
|
+
.where(period: periods.map { |period| ActiveRecordPeriods::PERIODS.fetch(period) },
|
|
108
|
+
period_start: buckets.values)
|
|
109
109
|
.pluck(:period, :period_start, :total_cost)
|
|
110
110
|
.each do |name, start, total|
|
|
111
111
|
period = index[[name, start.to_date]]
|
|
@@ -117,7 +117,7 @@ module LlmCostTracker
|
|
|
117
117
|
|
|
118
118
|
def fallback_period_total(period, time)
|
|
119
119
|
LlmCostTracker::LlmApiCall
|
|
120
|
-
.where(tracked_at:
|
|
120
|
+
.where(tracked_at: ActiveRecordPeriods.range_start(period, time)..time)
|
|
121
121
|
.sum(:total_cost)
|
|
122
122
|
.to_f
|
|
123
123
|
end
|
|
@@ -135,49 +135,11 @@ module LlmCostTracker
|
|
|
135
135
|
LlmCostTracker::PeriodTotal
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
def range_start_for(period, time)
|
|
139
|
-
utc_time = time.to_time.utc
|
|
140
|
-
|
|
141
|
-
case period
|
|
142
|
-
when :monthly then utc_time.beginning_of_month
|
|
143
|
-
when :daily then utc_time.beginning_of_day
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def bucket_for(period, time)
|
|
148
|
-
utc_time = time.to_time.utc
|
|
149
|
-
|
|
150
|
-
case period
|
|
151
|
-
when :monthly then utc_time.beginning_of_month.to_date
|
|
152
|
-
when :daily then utc_time.to_date
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
138
|
def unique_by(model, column)
|
|
157
139
|
return unless model.connection.supports_insert_conflict_target?
|
|
158
140
|
|
|
159
141
|
column
|
|
160
142
|
end
|
|
161
|
-
|
|
162
|
-
def total_upsert_sql(model)
|
|
163
|
-
Arel.sql(case model.connection.adapter_name
|
|
164
|
-
when /mysql/i
|
|
165
|
-
mysql_upsert_sql(model)
|
|
166
|
-
else
|
|
167
|
-
"total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
|
|
168
|
-
end)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def mysql_upsert_sql(model)
|
|
172
|
-
connection = model.connection
|
|
173
|
-
if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
|
|
174
|
-
connection.send(:supports_insert_raw_alias_syntax?)
|
|
175
|
-
values_reference = connection.quote_table_name("#{model.table_name}_values")
|
|
176
|
-
"total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
|
|
177
|
-
else
|
|
178
|
-
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
143
|
end
|
|
182
144
|
end
|
|
183
145
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "active_record_inbox"
|
|
4
|
+
require_relative "active_record_period_totals"
|
|
3
5
|
require_relative "active_record_rollups"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
@@ -11,8 +13,33 @@ module LlmCostTracker
|
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def save(event)
|
|
14
|
-
tags = stringify_tags(event.tags || {})
|
|
15
16
|
model = LlmCostTracker::LlmApiCall
|
|
17
|
+
attributes = attributes_for(event, model)
|
|
18
|
+
|
|
19
|
+
model.transaction do
|
|
20
|
+
call = model.create!(attributes)
|
|
21
|
+
ActiveRecordRollups.increment!(event)
|
|
22
|
+
call
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def insert_many(events)
|
|
27
|
+
events = Array(events)
|
|
28
|
+
return [] if events.empty?
|
|
29
|
+
|
|
30
|
+
model = LlmCostTracker::LlmApiCall
|
|
31
|
+
insertable = new_events(model, events)
|
|
32
|
+
|
|
33
|
+
if insertable.any?
|
|
34
|
+
rows = insertable.map { |event| attributes_for(event, model) }
|
|
35
|
+
model.insert_all!(rows, **insert_options)
|
|
36
|
+
ActiveRecordRollups.increment_many!(insertable)
|
|
37
|
+
end
|
|
38
|
+
events
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def attributes_for(event, model = LlmCostTracker::LlmApiCall)
|
|
42
|
+
tags = stringify_tags(event.tags || {})
|
|
16
43
|
columns = model.columns_hash
|
|
17
44
|
|
|
18
45
|
attributes = {
|
|
@@ -27,6 +54,7 @@ module LlmCostTracker
|
|
|
27
54
|
tags: tags_for_storage(tags, model),
|
|
28
55
|
tracked_at: event.tracked_at
|
|
29
56
|
}
|
|
57
|
+
attributes[:event_id] = event.event_id if columns.key?("event_id")
|
|
30
58
|
optional_attributes(event).each do |name, value|
|
|
31
59
|
attributes[name] = value if columns.key?(name.to_s)
|
|
32
60
|
end
|
|
@@ -35,23 +63,19 @@ module LlmCostTracker
|
|
|
35
63
|
attributes[:usage_source] = event.usage_source if columns.key?("usage_source")
|
|
36
64
|
attributes[:provider_response_id] = event.provider_response_id if columns.key?("provider_response_id")
|
|
37
65
|
|
|
38
|
-
|
|
39
|
-
call = model.create!(attributes)
|
|
40
|
-
ActiveRecordRollups.increment!(event)
|
|
41
|
-
call
|
|
42
|
-
end
|
|
66
|
+
attributes
|
|
43
67
|
end
|
|
44
68
|
|
|
45
69
|
def monthly_total(time: Time.now.utc)
|
|
46
|
-
|
|
70
|
+
period_totals(%i[monthly], time: time).fetch(:monthly)
|
|
47
71
|
end
|
|
48
72
|
|
|
49
73
|
def daily_total(time: Time.now.utc)
|
|
50
|
-
|
|
74
|
+
period_totals(%i[daily], time: time).fetch(:daily)
|
|
51
75
|
end
|
|
52
76
|
|
|
53
77
|
def period_totals(periods, time: Time.now.utc)
|
|
54
|
-
|
|
78
|
+
ActiveRecordPeriodTotals.call(periods, time: time)
|
|
55
79
|
end
|
|
56
80
|
|
|
57
81
|
def prune(cutoff:, batch_size:)
|
|
@@ -66,6 +90,15 @@ module LlmCostTracker
|
|
|
66
90
|
|
|
67
91
|
private
|
|
68
92
|
|
|
93
|
+
def new_events(model, events)
|
|
94
|
+
return events unless model.columns_hash.key?("event_id")
|
|
95
|
+
|
|
96
|
+
existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
|
|
97
|
+
events.reject { |event| existing_ids.include?(event.event_id) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def insert_options = { record_timestamps: true, returning: false }
|
|
101
|
+
|
|
69
102
|
def prune_batch(cutoff, batch_size)
|
|
70
103
|
LlmCostTracker::LlmApiCall.transaction do
|
|
71
104
|
rows = LlmCostTracker::LlmApiCall
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "monitor"
|
|
5
4
|
|
|
6
5
|
require_relative "stream_capture"
|
|
@@ -164,10 +163,9 @@ module LlmCostTracker
|
|
|
164
163
|
end
|
|
165
164
|
|
|
166
165
|
def capture_event(data, type:)
|
|
167
|
-
|
|
168
|
-
size = event_bytes(copied, type)
|
|
166
|
+
size = event_bytes(data, type)
|
|
169
167
|
if @captured_bytes + size <= StreamCapture::LIMIT_BYTES
|
|
170
|
-
@events << { event: type, data:
|
|
168
|
+
@events << { event: type, data: ValueHelpers.deep_dup(data) }
|
|
171
169
|
@captured_bytes += size
|
|
172
170
|
else
|
|
173
171
|
@overflowed = true
|
|
@@ -176,9 +174,22 @@ module LlmCostTracker
|
|
|
176
174
|
end
|
|
177
175
|
|
|
178
176
|
def event_bytes(data, type)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
type.to_s.bytesize + estimated_bytes(data) + 32
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def estimated_bytes(value)
|
|
181
|
+
case value
|
|
182
|
+
when Hash
|
|
183
|
+
value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
|
|
184
|
+
when Array
|
|
185
|
+
value.sum { |nested| estimated_bytes(nested) + 2 }
|
|
186
|
+
when String
|
|
187
|
+
value.bytesize + 2
|
|
188
|
+
when Numeric, true, false, nil
|
|
189
|
+
value.to_s.bytesize
|
|
190
|
+
else
|
|
191
|
+
value.to_s.bytesize + 2
|
|
192
|
+
end
|
|
182
193
|
end
|
|
183
194
|
|
|
184
195
|
def error_metadata(errored) = errored ? { stream_errored: true } : {}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "active_record_adapter"
|
|
3
4
|
require_relative "tag_key"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
@@ -9,11 +10,10 @@ module LlmCostTracker
|
|
|
9
10
|
key = TagKey.validate!(key)
|
|
10
11
|
column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
when /postgres/i
|
|
13
|
+
if ActiveRecordAdapter.postgresql?(model.connection)
|
|
14
14
|
json_column = model.tags_jsonb_column? ? column : "(#{column})::jsonb"
|
|
15
15
|
"#{json_column}->>#{model.connection.quote(key)}"
|
|
16
|
-
|
|
16
|
+
elsif ActiveRecordAdapter.mysql?(model.connection)
|
|
17
17
|
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
|
|
18
18
|
else
|
|
19
19
|
"json_extract(#{column}, #{model.connection.quote(json_path(key))})"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "active_record_adapter"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module TagsColumn
|
|
5
7
|
USAGE_BREAKDOWN_COLUMNS = %w[
|
|
@@ -79,7 +81,11 @@ module LlmCostTracker
|
|
|
79
81
|
def build_lct_schema_capabilities(columns, adapter_name)
|
|
80
82
|
tag_column = columns["tags"]
|
|
81
83
|
tags_jsonb = tag_column && (tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb")
|
|
82
|
-
tags_mysql_json =
|
|
84
|
+
tags_mysql_json =
|
|
85
|
+
tag_column &&
|
|
86
|
+
!tags_jsonb &&
|
|
87
|
+
tag_column.type == :json &&
|
|
88
|
+
ActiveRecordAdapter.mysql?(adapter_name)
|
|
83
89
|
|
|
84
90
|
{
|
|
85
91
|
tags_jsonb: tags_jsonb ? true : false,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
require_relative "storage/dispatcher"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
@@ -74,6 +76,7 @@ module LlmCostTracker
|
|
|
74
76
|
def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
|
|
75
77
|
provider_response_id:)
|
|
76
78
|
Event.new(
|
|
79
|
+
event_id: SecureRandom.uuid,
|
|
77
80
|
provider: provider,
|
|
78
81
|
model: model,
|
|
79
82
|
input_tokens: usage[:input_tokens],
|