llm_cost_tracker 0.5.2 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +8 -3
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  5. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  6. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  7. data/docs/architecture.md +28 -0
  8. data/docs/budgets.md +45 -0
  9. data/docs/configuration.md +65 -0
  10. data/docs/cookbook.md +185 -0
  11. data/docs/dashboard-overview.png +0 -0
  12. data/docs/dashboard.md +38 -0
  13. data/docs/extending.md +32 -0
  14. data/docs/operations.md +44 -0
  15. data/docs/pricing.md +94 -0
  16. data/docs/querying.md +36 -0
  17. data/docs/streaming.md +70 -0
  18. data/docs/technical/README.md +10 -0
  19. data/docs/technical/data-flow.md +70 -0
  20. data/docs/technical/extension-points.md +111 -0
  21. data/docs/technical/module-map.md +197 -0
  22. data/docs/technical/operational-notes.md +97 -0
  23. data/docs/upgrading.md +47 -0
  24. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  25. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  26. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  27. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  28. data/lib/llm_cost_tracker/configuration.rb +2 -1
  29. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  30. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  31. data/lib/llm_cost_tracker/doctor.rb +8 -1
  32. data/lib/llm_cost_tracker/event.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  40. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  41. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  43. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  44. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  45. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  46. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  47. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  48. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  49. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  50. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  51. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  52. data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
  53. data/lib/llm_cost_tracker/pricing.rb +25 -108
  54. data/lib/llm_cost_tracker/railtie.rb +1 -0
  55. data/lib/llm_cost_tracker/retention.rb +3 -9
  56. data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
  57. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  58. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  59. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  60. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  61. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  62. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  63. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  64. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  65. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  66. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
  67. data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
  68. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  69. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  70. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  71. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  72. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  73. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  74. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  75. data/lib/llm_cost_tracker/tracker.rb +3 -0
  76. data/lib/llm_cost_tracker/version.rb +1 -1
  77. data/lib/llm_cost_tracker.rb +39 -1
  78. data/lib/tasks/llm_cost_tracker.rake +49 -0
  79. metadata +47 -2
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "../active_record_adapter"
7
+ require_relative "../cost"
8
+ require_relative "../event"
9
+ require_relative "../inbox_event"
10
+ require_relative "active_record_periods"
11
+
12
+ module LlmCostTracker
13
+ module Storage
14
+ class ActiveRecordInbox
15
+ TABLE_NAME = "llm_cost_tracker_inbox_events"
16
+ LEASE_TABLE_NAME = "llm_cost_tracker_ingestor_leases"
17
+ MAX_ATTEMPTS = 5
18
+
19
+ class << self
20
+ def reset!
21
+ remove_instance_variable(:@enabled) if instance_variable_defined?(:@enabled)
22
+ end
23
+
24
+ def enabled?
25
+ return @enabled unless @enabled.nil?
26
+
27
+ model = LlmCostTracker::LlmApiCall
28
+ @enabled = model.columns_hash.key?("event_id") &&
29
+ model.connection.data_source_exists?(TABLE_NAME) &&
30
+ model.connection.data_source_exists?(LEASE_TABLE_NAME)
31
+ rescue StandardError
32
+ @enabled = false
33
+ end
34
+
35
+ def save(event)
36
+ insert_row(row_for(event))
37
+ ActiveRecordIngestor.ensure_started
38
+ event
39
+ end
40
+
41
+ def pending_period_totals(periods, time:)
42
+ return periods.to_h { |period| [period, 0.0] } unless enabled?
43
+
44
+ periods.to_h do |period|
45
+ [period, pending_period_total(period, time)]
46
+ end
47
+ end
48
+
49
+ def event_from_row(row)
50
+ payload = JSON.parse(row.payload)
51
+ cost = payload["cost"] && LlmCostTracker::Cost.new(**symbolize_keys(payload["cost"]))
52
+
53
+ LlmCostTracker::Event.new(
54
+ event_id: payload.fetch("event_id"),
55
+ provider: payload.fetch("provider"),
56
+ model: payload.fetch("model"),
57
+ input_tokens: payload.fetch("input_tokens"),
58
+ output_tokens: payload.fetch("output_tokens"),
59
+ total_tokens: payload.fetch("total_tokens"),
60
+ cache_read_input_tokens: payload.fetch("cache_read_input_tokens"),
61
+ cache_write_input_tokens: payload.fetch("cache_write_input_tokens"),
62
+ hidden_output_tokens: payload.fetch("hidden_output_tokens"),
63
+ pricing_mode: payload["pricing_mode"],
64
+ cost: cost,
65
+ tags: payload.fetch("tags"),
66
+ latency_ms: payload["latency_ms"],
67
+ stream: payload.fetch("stream"),
68
+ usage_source: payload["usage_source"],
69
+ provider_response_id: payload["provider_response_id"],
70
+ tracked_at: Time.iso8601(payload.fetch("tracked_at"))
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def row_for(event)
77
+ now = Time.now.utc
78
+ {
79
+ event_id: event.event_id,
80
+ total_cost: event.cost&.total_cost,
81
+ tracked_at: event.tracked_at,
82
+ payload: JSON.generate(payload_for(event)),
83
+ attempts: 0,
84
+ created_at: now,
85
+ updated_at: now
86
+ }
87
+ end
88
+
89
+ def payload_for(event)
90
+ {
91
+ event_id: event.event_id,
92
+ provider: event.provider,
93
+ model: event.model,
94
+ input_tokens: event.input_tokens,
95
+ output_tokens: event.output_tokens,
96
+ total_tokens: event.total_tokens,
97
+ cache_read_input_tokens: event.cache_read_input_tokens,
98
+ cache_write_input_tokens: event.cache_write_input_tokens,
99
+ hidden_output_tokens: event.hidden_output_tokens,
100
+ pricing_mode: event.pricing_mode,
101
+ cost: event.cost&.to_h,
102
+ tags: event.tags || {},
103
+ latency_ms: event.latency_ms,
104
+ stream: event.stream,
105
+ usage_source: event.usage_source,
106
+ provider_response_id: event.provider_response_id,
107
+ tracked_at: event.tracked_at.iso8601(6)
108
+ }
109
+ end
110
+
111
+ def insert_row(row)
112
+ connection = LlmCostTracker::LlmApiCall.connection
113
+ if connection.transaction_open? && !sqlite_database?(connection)
114
+ insert_with_separate_connection(row)
115
+ else
116
+ execute_insert(connection, row)
117
+ end
118
+ rescue ActiveRecord::ConnectionTimeoutError => e
119
+ raise LlmCostTracker::Error,
120
+ "ActiveRecord inbox could not checkout a separate database connection: #{e.message}"
121
+ end
122
+
123
+ def insert_with_separate_connection(row)
124
+ pool = LlmCostTracker::LlmApiCall.connection_pool
125
+ connection = pool.checkout
126
+ begin
127
+ connection.transaction(requires_new: true) { execute_insert(connection, row) }
128
+ ensure
129
+ pool.checkin(connection)
130
+ end
131
+ end
132
+
133
+ def execute_insert(connection, row)
134
+ columns = row.keys
135
+ quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
136
+ quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
137
+ table = connection.quote_table_name(TABLE_NAME)
138
+
139
+ connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
140
+ end
141
+
142
+ def pending_period_total(period, time)
143
+ LlmCostTracker::InboxEvent
144
+ .where("attempts < ?", MAX_ATTEMPTS)
145
+ .where(tracked_at: period_range(period, time))
146
+ .sum(:total_cost)
147
+ .to_f
148
+ end
149
+
150
+ def period_range(period, time)
151
+ utc_time = time.to_time.utc
152
+ ActiveRecordPeriods.range_start(period, utc_time)..utc_time
153
+ end
154
+
155
+ def symbolize_keys(hash)
156
+ hash.transform_keys(&:to_sym)
157
+ end
158
+
159
+ def sqlite_database?(connection)
160
+ ActiveRecordAdapter.sqlite?(connection)
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inbox_event"
4
+ require_relative "active_record_inbox"
5
+ require_relative "active_record_store"
6
+
7
+ module LlmCostTracker
8
+ module Storage
9
+ class ActiveRecordInboxBatch
10
+ BATCH_SIZE = 100
11
+ LOCK_TIMEOUT_SECONDS = 30
12
+
13
+ def initialize(identity:)
14
+ @identity = identity
15
+ end
16
+
17
+ def ingest
18
+ rows = claim
19
+ return 0 if rows.empty?
20
+
21
+ valid_rows, events = decode(rows)
22
+ persist(valid_rows, events) if events.any?
23
+ rows.size
24
+ rescue StandardError => e
25
+ rows_to_mark = valid_rows&.any? ? valid_rows : rows
26
+ mark_failed(rows_to_mark, e) if rows_to_mark&.any?
27
+ raise
28
+ end
29
+
30
+ def pending? = model.where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS).exists?
31
+
32
+ def claimable? = claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
33
+
34
+ def mark_failed(rows, error)
35
+ message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
36
+ now = Time.now.utc
37
+ model
38
+ .where(id: rows.map(&:id), locked_by: identity)
39
+ .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :identity
47
+
48
+ def claim
49
+ now = Time.now.utc
50
+ cutoff = now - LOCK_TIMEOUT_SECONDS
51
+ model.transaction do
52
+ rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
53
+ ids = rows.map(&:id)
54
+ next [] if ids.empty?
55
+
56
+ updates = model.sanitize_sql_array(
57
+ ["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
58
+ )
59
+ model.where(id: ids).update_all(updates)
60
+ model.where(id: ids, locked_by: identity).order(:id).to_a
61
+ end
62
+ end
63
+
64
+ def decode(rows)
65
+ valid_rows = []
66
+ events = []
67
+ rows.each do |row|
68
+ events << ActiveRecordInbox.event_from_row(row)
69
+ valid_rows << row
70
+ rescue StandardError => e
71
+ mark_failed([row], e)
72
+ end
73
+ [valid_rows, events]
74
+ end
75
+
76
+ def persist(rows, events)
77
+ LlmCostTracker::LlmApiCall.transaction do
78
+ ActiveRecordStore.insert_many(events)
79
+ model.where(id: rows.map(&:id), locked_by: identity).delete_all
80
+ end
81
+ end
82
+
83
+ def claimable_scope(cutoff)
84
+ model
85
+ .where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS)
86
+ .where("locked_at IS NULL OR locked_at < ?", cutoff)
87
+ end
88
+
89
+ def model = LlmCostTracker::InboxEvent
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "../inbox_event"
6
+ require_relative "../logging"
7
+ require_relative "active_record_connection_cleanup"
8
+ require_relative "active_record_inbox"
9
+ require_relative "active_record_inbox_batch"
10
+ require_relative "active_record_ingestor_lease"
11
+
12
+ module LlmCostTracker
13
+ module Storage
14
+ class ActiveRecordIngestor
15
+ INTERVAL_SECONDS = 0.25
16
+ IDLE_INTERVAL_SECONDS = 1.0
17
+ MAX_IDLE_INTERVAL_SECONDS = 5.0
18
+ LEASE_SECONDS = 10
19
+ FLUSH_TIMEOUT_SECONDS = 10
20
+ class << self
21
+ def ensure_started
22
+ return unless ActiveRecordInbox.enabled?
23
+
24
+ thread = mutex.synchronize do
25
+ reset_after_fork!
26
+ unless @thread&.alive?
27
+ @stop_requested = false
28
+ generation = next_generation
29
+ @thread = Thread.new { run(generation) }
30
+ @thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
31
+ @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
32
+ end
33
+ @thread
34
+ end
35
+ wake_thread(thread)
36
+ end
37
+
38
+ def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
39
+ return true unless ActiveRecordInbox.enabled?
40
+
41
+ deadline = Time.now.utc + timeout
42
+ loop do
43
+ return true unless pending_events?
44
+ return false if Time.now.utc >= deadline
45
+
46
+ processed = ingest_once(require_lease: require_lease)
47
+ return false if processed.zero? && !sleep_until_next_flush(deadline)
48
+ end
49
+ end
50
+
51
+ def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
52
+ ActiveRecordInbox.reset!
53
+ thread = mutex.synchronize do
54
+ @stop_requested = true
55
+ next_generation
56
+ @thread
57
+ end
58
+ wake_thread(thread)
59
+ thread&.join([timeout, 1].min)
60
+ drain ? flush!(timeout: timeout, require_lease: true) : true
61
+ rescue StandardError => e
62
+ handle_error(e)
63
+ false
64
+ ensure
65
+ mutex.synchronize { @thread = nil if @thread.equal?(thread) }
66
+ end
67
+
68
+ def reset!
69
+ thread = mutex.synchronize do
70
+ @stop_requested = true
71
+ next_generation
72
+ thread = @thread
73
+ @thread = nil
74
+ @pid = nil
75
+ @identity = nil
76
+ thread
77
+ end
78
+ wake_thread(thread)
79
+ end
80
+
81
+ def ingest_once(require_lease: true)
82
+ return 0 unless ActiveRecordInbox.enabled?
83
+ return 0 unless claimable_events?
84
+ return 0 if require_lease && !acquire_lease
85
+
86
+ inbox_batch.ingest
87
+ rescue StandardError => e
88
+ handle_error(e)
89
+ 0
90
+ end
91
+
92
+ private
93
+
94
+ def mutex = @mutex ||= Mutex.new
95
+
96
+ def run(generation)
97
+ idle_interval = IDLE_INTERVAL_SECONDS
98
+ loop do
99
+ break if stop_requested?(generation)
100
+
101
+ processed = executor_wrap { ingest_once }
102
+ ActiveRecordConnectionCleanup.release!
103
+ if processed.zero?
104
+ sleep(idle_interval)
105
+ idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
106
+ else
107
+ idle_interval = IDLE_INTERVAL_SECONDS
108
+ end
109
+ rescue StandardError => e
110
+ handle_error(e)
111
+ ActiveRecordConnectionCleanup.release!
112
+ sleep(idle_interval)
113
+ end
114
+ ensure
115
+ ActiveRecordConnectionCleanup.release!
116
+ mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
117
+ end
118
+
119
+ def sleep_until_next_flush(deadline)
120
+ duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
121
+ sleep(duration) if duration.positive?
122
+ end
123
+
124
+ def stop_requested?(generation) = mutex.synchronize { @stop_requested || generation != @generation }
125
+
126
+ def reset_after_fork!
127
+ return if @pid == Process.pid
128
+
129
+ @pid = Process.pid
130
+ @thread = nil
131
+ @identity = nil
132
+ end
133
+
134
+ def next_generation = (@generation = @generation.to_i + 1)
135
+
136
+ def wake_thread(thread)
137
+ thread&.wakeup if thread&.alive?
138
+ rescue ThreadError
139
+ nil
140
+ end
141
+
142
+ def executor_wrap(&)
143
+ executor = rails_executor
144
+ return yield unless executor
145
+
146
+ executor.wrap(&)
147
+ end
148
+
149
+ def rails_executor
150
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:executor)
151
+
152
+ Rails.application.executor
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ def identity = @identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
158
+
159
+ def acquire_lease = ActiveRecordIngestorLease.new(identity: identity, seconds: LEASE_SECONDS).acquire
160
+
161
+ def pending_events? = inbox_batch.pending?
162
+
163
+ def claimable_events? = inbox_batch.claimable?
164
+
165
+ def inbox_batch = ActiveRecordInboxBatch.new(identity: identity)
166
+
167
+ def handle_error(error)
168
+ Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}") unless
169
+ LlmCostTracker.configuration.storage_error_behavior == :ignore
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -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