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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +8 -3
- 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 +28 -0
- data/docs/budgets.md +45 -0
- data/docs/configuration.md +65 -0
- data/docs/cookbook.md +185 -0
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +38 -0
- data/docs/extending.md +32 -0
- data/docs/operations.md +44 -0
- data/docs/pricing.md +94 -0
- data/docs/querying.md +36 -0
- data/docs/streaming.md +70 -0
- data/docs/technical/README.md +10 -0
- data/docs/technical/data-flow.md +70 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +97 -0
- data/docs/upgrading.md +47 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
- data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
- data/lib/llm_cost_tracker/configuration.rb +2 -1
- data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +8 -1
- 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/integrations/anthropic.rb +41 -2
- data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
- data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
- data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- data/lib/llm_cost_tracker/period_grouping.rb +4 -3
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
- data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/retention.rb +3 -9
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
- 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 +59 -55
- data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
- data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
- data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
- data/lib/llm_cost_tracker/storage/registry.rb +63 -0
- data/lib/llm_cost_tracker/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- 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 +39 -1
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +47 -2
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "active_record_periods"
|
|
6
|
+
require_relative "active_record_rollup_batch"
|
|
7
|
+
require_relative "active_record_rollup_upsert_sql"
|
|
8
|
+
|
|
3
9
|
module LlmCostTracker
|
|
4
10
|
module Storage
|
|
5
11
|
class ActiveRecordRollups
|
|
6
|
-
PERIODS = {
|
|
7
|
-
monthly: "month",
|
|
8
|
-
daily: "day"
|
|
9
|
-
}.freeze
|
|
10
|
-
|
|
11
12
|
class << self
|
|
12
13
|
def reset!
|
|
13
14
|
remove_instance_variable(:@period_totals_enabled) if instance_variable_defined?(:@period_totals_enabled)
|
|
@@ -20,22 +21,37 @@ module LlmCostTracker
|
|
|
20
21
|
model = period_total_model
|
|
21
22
|
model.upsert_all(
|
|
22
23
|
period_rows(event),
|
|
23
|
-
on_duplicate:
|
|
24
|
+
on_duplicate: ActiveRecordRollupUpsertSql.call(model),
|
|
24
25
|
record_timestamps: true,
|
|
25
26
|
unique_by: unique_by(model, %i[period period_start])
|
|
26
27
|
)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def
|
|
30
|
-
|
|
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),
|
|
39
|
+
record_timestamps: true,
|
|
40
|
+
unique_by: unique_by(model, %i[period period_start])
|
|
41
|
+
)
|
|
31
42
|
end
|
|
32
43
|
|
|
33
|
-
def
|
|
34
|
-
|
|
44
|
+
def decrement!(call_rows)
|
|
45
|
+
return unless period_totals_enabled?
|
|
46
|
+
|
|
47
|
+
totals = period_decrement_totals(call_rows)
|
|
48
|
+
return if totals.empty?
|
|
49
|
+
|
|
50
|
+
apply_decrements(totals)
|
|
35
51
|
end
|
|
36
52
|
|
|
37
53
|
def period_totals(periods, time: Time.now.utc)
|
|
38
|
-
periods =
|
|
54
|
+
periods = ActiveRecordPeriods.valid_keys(periods)
|
|
39
55
|
return {} if periods.empty?
|
|
40
56
|
|
|
41
57
|
if period_totals_enabled?
|
|
@@ -48,22 +64,48 @@ module LlmCostTracker
|
|
|
48
64
|
private
|
|
49
65
|
|
|
50
66
|
def period_rows(event)
|
|
51
|
-
PERIODS.map do |period, name|
|
|
67
|
+
ActiveRecordPeriods::PERIODS.map do |period, name|
|
|
52
68
|
{
|
|
53
69
|
period: name,
|
|
54
|
-
period_start:
|
|
70
|
+
period_start: ActiveRecordPeriods.bucket(period, event.tracked_at),
|
|
55
71
|
total_cost: event.cost.total_cost
|
|
56
72
|
}
|
|
57
73
|
end
|
|
58
74
|
end
|
|
59
75
|
|
|
76
|
+
def period_decrement_totals(call_rows)
|
|
77
|
+
call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
|
|
78
|
+
_id, tracked_at, total_cost = row
|
|
79
|
+
next unless total_cost
|
|
80
|
+
|
|
81
|
+
ActiveRecordPeriods::PERIODS.each_key do |period|
|
|
82
|
+
totals[[period, ActiveRecordPeriods.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def apply_decrements(totals)
|
|
88
|
+
model = period_total_model
|
|
89
|
+
now = Time.now.utc
|
|
90
|
+
|
|
91
|
+
totals.each do |(period, period_start), amount|
|
|
92
|
+
row = model.lock.find_by(period: ActiveRecordPeriods::PERIODS.fetch(period), period_start: period_start)
|
|
93
|
+
next unless row
|
|
94
|
+
|
|
95
|
+
row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def decremented_total(current, amount) = [BigDecimal(current.to_s) - amount, BigDecimal("0")].max
|
|
100
|
+
|
|
60
101
|
def rollup_period_totals(periods, time)
|
|
61
|
-
buckets = periods.to_h { |period| [period,
|
|
62
|
-
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] }
|
|
63
104
|
totals = periods.to_h { |period| [period, 0.0] }
|
|
64
105
|
|
|
65
106
|
period_total_model
|
|
66
|
-
.where(period: periods.map { |period| PERIODS.fetch(period) },
|
|
107
|
+
.where(period: periods.map { |period| ActiveRecordPeriods::PERIODS.fetch(period) },
|
|
108
|
+
period_start: buckets.values)
|
|
67
109
|
.pluck(:period, :period_start, :total_cost)
|
|
68
110
|
.each do |name, start, total|
|
|
69
111
|
period = index[[name, start.to_date]]
|
|
@@ -75,7 +117,7 @@ module LlmCostTracker
|
|
|
75
117
|
|
|
76
118
|
def fallback_period_total(period, time)
|
|
77
119
|
LlmCostTracker::LlmApiCall
|
|
78
|
-
.where(tracked_at:
|
|
120
|
+
.where(tracked_at: ActiveRecordPeriods.range_start(period, time)..time)
|
|
79
121
|
.sum(:total_cost)
|
|
80
122
|
.to_f
|
|
81
123
|
end
|
|
@@ -93,49 +135,11 @@ module LlmCostTracker
|
|
|
93
135
|
LlmCostTracker::PeriodTotal
|
|
94
136
|
end
|
|
95
137
|
|
|
96
|
-
def range_start_for(period, time)
|
|
97
|
-
utc_time = time.to_time.utc
|
|
98
|
-
|
|
99
|
-
case period
|
|
100
|
-
when :monthly then utc_time.beginning_of_month
|
|
101
|
-
when :daily then utc_time.beginning_of_day
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def bucket_for(period, time)
|
|
106
|
-
utc_time = time.to_time.utc
|
|
107
|
-
|
|
108
|
-
case period
|
|
109
|
-
when :monthly then utc_time.beginning_of_month.to_date
|
|
110
|
-
when :daily then utc_time.to_date
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
138
|
def unique_by(model, column)
|
|
115
139
|
return unless model.connection.supports_insert_conflict_target?
|
|
116
140
|
|
|
117
141
|
column
|
|
118
142
|
end
|
|
119
|
-
|
|
120
|
-
def total_upsert_sql(model)
|
|
121
|
-
Arel.sql(case model.connection.adapter_name
|
|
122
|
-
when /mysql/i
|
|
123
|
-
mysql_upsert_sql(model)
|
|
124
|
-
else
|
|
125
|
-
"total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
|
|
126
|
-
end)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def mysql_upsert_sql(model)
|
|
130
|
-
connection = model.connection
|
|
131
|
-
if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
|
|
132
|
-
connection.send(:supports_insert_raw_alias_syntax?)
|
|
133
|
-
values_reference = connection.quote_table_name("#{model.table_name}_values")
|
|
134
|
-
"total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
|
|
135
|
-
else
|
|
136
|
-
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
143
|
end
|
|
140
144
|
end
|
|
141
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,27 +63,58 @@ 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)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prune(cutoff:, batch_size:)
|
|
82
|
+
deleted = 0
|
|
83
|
+
loop do
|
|
84
|
+
batch = prune_batch(cutoff, batch_size)
|
|
85
|
+
deleted += batch
|
|
86
|
+
break if batch < batch_size
|
|
87
|
+
end
|
|
88
|
+
deleted
|
|
55
89
|
end
|
|
56
90
|
|
|
57
91
|
private
|
|
58
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
|
+
|
|
102
|
+
def prune_batch(cutoff, batch_size)
|
|
103
|
+
LlmCostTracker::LlmApiCall.transaction do
|
|
104
|
+
rows = LlmCostTracker::LlmApiCall
|
|
105
|
+
.where(tracked_at: ...cutoff)
|
|
106
|
+
.order(:id)
|
|
107
|
+
.limit(batch_size)
|
|
108
|
+
.lock
|
|
109
|
+
.pluck(:id, :tracked_at, :total_cost)
|
|
110
|
+
next 0 if rows.empty?
|
|
111
|
+
|
|
112
|
+
deleted = LlmCostTracker::LlmApiCall.where(id: rows.map(&:first)).delete_all
|
|
113
|
+
ActiveRecordRollups.decrement!(rows) if deleted.positive?
|
|
114
|
+
deleted
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
59
118
|
def stringify_tags(tags)
|
|
60
119
|
tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
|
|
61
120
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
class CustomBackend
|
|
8
|
+
class << self
|
|
9
|
+
def save(event)
|
|
10
|
+
result = LlmCostTracker.configuration.custom_storage&.call(event)
|
|
11
|
+
result == false ? false : event
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify
|
|
15
|
+
if LlmCostTracker.configuration.custom_storage.respond_to?(:call)
|
|
16
|
+
return [
|
|
17
|
+
VerificationResult.new(
|
|
18
|
+
:ok,
|
|
19
|
+
"storage",
|
|
20
|
+
"custom storage callable configured; external sink was not invoked"
|
|
21
|
+
)
|
|
22
|
+
]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
[
|
|
26
|
+
VerificationResult.new(:error, "storage", "custom storage backend requires config.custom_storage")
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../logging"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
require_relative "active_record_backend"
|
|
6
|
+
require_relative "custom_backend"
|
|
7
|
+
require_relative "log_backend"
|
|
4
8
|
|
|
5
9
|
module LlmCostTracker
|
|
6
10
|
module Storage
|
|
7
11
|
class Dispatcher
|
|
8
12
|
class << self
|
|
9
13
|
def save(event)
|
|
10
|
-
|
|
11
|
-
case config.storage_backend
|
|
12
|
-
when :log then log_event(event, config)
|
|
13
|
-
when :active_record then active_record_save(event)
|
|
14
|
-
when :custom then custom_save(event, config)
|
|
15
|
-
end
|
|
14
|
+
backend.save(event)
|
|
16
15
|
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
17
16
|
raise
|
|
18
17
|
rescue StandardError => e
|
|
@@ -22,34 +21,8 @@ module LlmCostTracker
|
|
|
22
21
|
|
|
23
22
|
private
|
|
24
23
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
"tokens=#{event.total_tokens} " \
|
|
28
|
-
"cost=#{log_cost_label(event)}"
|
|
29
|
-
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
30
|
-
message += " stream=#{event.stream}" if event.stream
|
|
31
|
-
message += " source=#{event.usage_source}" if event.usage_source
|
|
32
|
-
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
33
|
-
|
|
34
|
-
Logging.log(config.log_level, message)
|
|
35
|
-
event
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
|
|
39
|
-
|
|
40
|
-
def active_record_save(event)
|
|
41
|
-
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
42
|
-
require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
43
|
-
|
|
44
|
-
ActiveRecordStore.save(event)
|
|
45
|
-
event
|
|
46
|
-
rescue LoadError => e
|
|
47
|
-
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def custom_save(event, config)
|
|
51
|
-
result = config.custom_storage&.call(event)
|
|
52
|
-
result == false ? false : event
|
|
24
|
+
def backend
|
|
25
|
+
Registry.fetch(LlmCostTracker.configuration.storage_backend)
|
|
53
26
|
end
|
|
54
27
|
|
|
55
28
|
def handle_error(error)
|
|
@@ -64,5 +37,9 @@ module LlmCostTracker
|
|
|
64
37
|
end
|
|
65
38
|
end
|
|
66
39
|
end
|
|
40
|
+
|
|
41
|
+
Registry.register(:log, LogBackend)
|
|
42
|
+
Registry.register(:active_record, ActiveRecordBackend)
|
|
43
|
+
Registry.register(:custom, CustomBackend)
|
|
67
44
|
end
|
|
68
45
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../logging"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Storage
|
|
8
|
+
class LogBackend
|
|
9
|
+
class << self
|
|
10
|
+
def save(event)
|
|
11
|
+
config = LlmCostTracker.configuration
|
|
12
|
+
message = "#{event.provider}/#{event.model} " \
|
|
13
|
+
"tokens=#{event.total_tokens} " \
|
|
14
|
+
"cost=#{cost_label(event)}"
|
|
15
|
+
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
16
|
+
message += " stream=#{event.stream}" if event.stream
|
|
17
|
+
message += " source=#{event.usage_source}" if event.usage_source
|
|
18
|
+
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
19
|
+
|
|
20
|
+
Logging.log(config.log_level, message)
|
|
21
|
+
event
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def verify
|
|
25
|
+
[
|
|
26
|
+
VerificationResult.new(:ok, "storage", "log backend configured; capture writes to logs only")
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def cost_label(event)
|
|
33
|
+
event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Storage
|
|
9
|
+
VerificationResult = Data.define(:status, :name, :message)
|
|
10
|
+
|
|
11
|
+
module Registry
|
|
12
|
+
MUTEX = Monitor.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def register(name, backend)
|
|
16
|
+
name = normalize_name(name)
|
|
17
|
+
validate_backend!(backend)
|
|
18
|
+
MUTEX.synchronize { @backends = backends.merge(name => backend).freeze }
|
|
19
|
+
backend
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(name)
|
|
23
|
+
key = normalize_name(name)
|
|
24
|
+
backends.fetch(key) do
|
|
25
|
+
raise Error, "Unknown storage_backend: #{key.inspect}. Use one of: #{names.join(', ')}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def registered?(name)
|
|
30
|
+
backends.key?(normalize_name(name))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def names
|
|
34
|
+
backends.keys
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def backends
|
|
40
|
+
@backends || MUTEX.synchronize { @backends ||= {}.freeze }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_name(name)
|
|
44
|
+
name.to_sym
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_backend!(backend)
|
|
48
|
+
return if backend.respond_to?(:save)
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "storage backend must respond to save"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.register(name, backend)
|
|
56
|
+
Registry.register(name, backend)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.backends
|
|
60
|
+
Registry.names
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -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 } : {}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "active_record_adapter"
|
|
4
|
+
require_relative "tag_key"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module TagSql
|
|
8
|
+
class << self
|
|
9
|
+
def value_expression(model, key, table_name:)
|
|
10
|
+
key = TagKey.validate!(key)
|
|
11
|
+
column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
|
|
12
|
+
|
|
13
|
+
if ActiveRecordAdapter.postgresql?(model.connection)
|
|
14
|
+
json_column = model.tags_jsonb_column? ? column : "(#{column})::jsonb"
|
|
15
|
+
"#{json_column}->>#{model.connection.quote(key)}"
|
|
16
|
+
elsif ActiveRecordAdapter.mysql?(model.connection)
|
|
17
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
|
|
18
|
+
else
|
|
19
|
+
"json_extract(#{column}, #{model.connection.quote(json_path(key))})"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def value_label(value)
|
|
24
|
+
value.nil? || value == "" ? "(untagged)" : value.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def json_path(key)
|
|
30
|
+
"$.\"#{key}\""
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -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],
|