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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  4. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  5. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  6. data/docs/architecture.md +1 -1
  7. data/docs/configuration.md +1 -1
  8. data/docs/technical/data-flow.md +8 -5
  9. data/docs/technical/operational-notes.md +21 -1
  10. data/docs/upgrading.md +1 -0
  11. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  12. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  13. data/lib/llm_cost_tracker/doctor.rb +2 -0
  14. data/lib/llm_cost_tracker/event.rb +1 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  20. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  21. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  22. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  23. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  24. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  25. data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
  26. data/lib/llm_cost_tracker/railtie.rb +1 -0
  27. data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
  28. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  29. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  30. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  31. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  32. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  33. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  34. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  35. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  36. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  37. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
  38. data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
  39. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  40. data/lib/llm_cost_tracker/tag_sql.rb +3 -3
  41. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  42. data/lib/llm_cost_tracker/tracker.rb +3 -0
  43. data/lib/llm_cost_tracker/version.rb +1 -1
  44. data/lib/llm_cost_tracker.rb +36 -1
  45. 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: total_upsert_sql(model),
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 = periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
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: bucket_for(period, event.tracked_at),
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, bucket_for(period, tracked_at)]] += decimal(total_cost)
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, bucket_for(period, time)] }
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) }, period_start: buckets.values)
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: range_start_for(period, time)..time)
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
- model.transaction do
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
- ActiveRecordRollups.monthly_total(time: time)
70
+ period_totals(%i[monthly], time: time).fetch(:monthly)
47
71
  end
48
72
 
49
73
  def daily_total(time: Time.now.utc)
50
- ActiveRecordRollups.daily_total(time: time)
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
- ActiveRecordRollups.period_totals(periods, time: time)
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
- copied = ValueHelpers.deep_dup(data)
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: copied }
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
- JSON.generate(event: type, data: data).bytesize
180
- rescue JSON::GeneratorError, TypeError
181
- type.to_s.bytesize + data.to_s.bytesize
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
- case model.connection.adapter_name
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
- when /mysql/i
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 = tag_column && !tags_jsonb && tag_column.type == :json && adapter_name.match?(/mysql/i)
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],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.5.3"
4
+ VERSION = "0.6.0"
5
5
  end