llm_cost_tracker 0.5.3 → 0.6.1
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 +36 -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/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 -19
- data/docs/architecture.md +0 -28
- data/docs/budgets.md +0 -45
- data/docs/configuration.md +0 -65
- data/docs/cookbook.md +0 -185
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +0 -38
- data/docs/extending.md +0 -32
- data/docs/operations.md +0 -44
- data/docs/pricing.md +0 -94
- data/docs/querying.md +0 -36
- data/docs/streaming.md +0 -70
- data/docs/technical/README.md +0 -10
- data/docs/technical/data-flow.md +0 -67
- data/docs/technical/extension-points.md +0 -111
- data/docs/technical/module-map.md +0 -197
- data/docs/technical/operational-notes.md +0 -77
- data/docs/upgrading.md +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6942269ea8e3ce5f22bb30a1dc6f6c73ee056b33f4f5d7697d1749ce5742ca93
|
|
4
|
+
data.tar.gz: 1b0712cfcbea56ee0e03dd1cb6c400969fc485c5648f75616140a62f6e82fc02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9793cc211dd08669deecf4a1d6a242ec4f2fff3fc41922472d158002731f4db40d618163d6fb054eeb36cae4e8aa708a0055a0fe1820faf88081b26de407107a
|
|
7
|
+
data.tar.gz: 05ed21f8309b16475b4ca7a0a938c999336f0ebb09baf897225422c69dd8e7e81f73ef01e69ff2d4c80d392f49230c212a87ec5c925c6e242b24942c5369f37f
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.1] - 2026-04-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Exclude repository documentation from the published gem package.
|
|
12
|
+
|
|
13
|
+
## [0.6.0] - 2026-04-29
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Durable ActiveRecord ingestion through `llm_cost_tracker_inbox_events` and `llm_cost_tracker_ingestor_leases`.
|
|
18
|
+
- `llm_cost_tracker:add_ingestion` generator for upgrading existing ActiveRecord installs.
|
|
19
|
+
- `LlmCostTracker.flush!` and `LlmCostTracker.shutdown!` for draining or stopping durable ingestion.
|
|
20
|
+
- Doctor diagnostics for missing durable ingestion schema, stale pending inbox rows, and quarantined inbox rows.
|
|
21
|
+
- PostgreSQL and MySQL smoke checks for ActiveRecord durable ingestion.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Fresh ActiveRecord installs now include durable ingestion tables, event IDs, and production indexes.
|
|
26
|
+
- ActiveRecord budget totals now read stored period rollups plus pending inbox totals while durable ingestion is enabled.
|
|
27
|
+
- ActiveRecord writes now use a durable inbox before batching ledger inserts and period rollup updates when ingestion tables are present.
|
|
28
|
+
- Pricing lookup now caches normalized runtime price tables and model matches by configuration generation.
|
|
29
|
+
- Stream capture now estimates buffered event size without serializing every captured event.
|
|
30
|
+
- CSV export now selects only exported columns instead of loading full ActiveRecord objects.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- ActiveRecord rollups no longer double-count retried events when duplicate event IDs race across workers.
|
|
35
|
+
- Invalid inbox rows are retried and quarantined without blocking healthy rows behind them.
|
|
36
|
+
- Idle ingestors no longer acquire the leader lease while the inbox is empty.
|
|
37
|
+
- ActiveRecord inbox writes now fail honestly when a separate connection is unavailable inside a caller transaction.
|
|
38
|
+
- Ingestor shutdown/reset no longer lets an old sleeping thread resume as a second local ingestor.
|
|
39
|
+
- `flush!` now returns `false` instead of raising when its timeout expires during ingestion.
|
|
40
|
+
- ActiveRecord adapter family detection now works through known adapter class ancestry with an adapter-name fallback.
|
|
41
|
+
- CSV export now emits `{}` for invalid stored tag payloads.
|
|
42
|
+
|
|
7
43
|
## [0.5.3] - 2026-04-28
|
|
8
44
|
|
|
9
45
|
### Added
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "csv"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
class CallsController < ApplicationController
|
|
@@ -54,32 +55,45 @@ module LlmCostTracker
|
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def render_csv(relation)
|
|
57
|
-
|
|
58
|
+
fields = csv_fields
|
|
58
59
|
CSV.generate do |csv|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
csv << headers
|
|
64
|
-
|
|
65
|
-
relation.each do |call|
|
|
66
|
-
row = [
|
|
67
|
-
call.tracked_at&.utc&.iso8601,
|
|
68
|
-
csv_safe(call.provider),
|
|
69
|
-
csv_safe(call.model),
|
|
70
|
-
call.input_tokens,
|
|
71
|
-
call.output_tokens,
|
|
72
|
-
call.total_tokens,
|
|
73
|
-
call.total_cost
|
|
74
|
-
]
|
|
75
|
-
row << call.latency_ms if latency
|
|
76
|
-
row << csv_safe(call.provider_response_id) if LlmApiCall.provider_response_id_column?
|
|
77
|
-
row << csv_safe(call.parsed_tags.to_json)
|
|
78
|
-
csv << row
|
|
60
|
+
csv << fields.map(&:to_s)
|
|
61
|
+
|
|
62
|
+
relation.pluck(*fields).each do |values|
|
|
63
|
+
csv << fields.zip(values).map { |field, value| csv_value(field, value) }
|
|
79
64
|
end
|
|
80
65
|
end
|
|
81
66
|
end
|
|
82
67
|
|
|
68
|
+
def csv_fields
|
|
69
|
+
fields = %i[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
|
|
70
|
+
fields << :latency_ms if LlmApiCall.latency_column?
|
|
71
|
+
fields << :provider_response_id if LlmApiCall.provider_response_id_column?
|
|
72
|
+
fields << :tags
|
|
73
|
+
fields
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def csv_value(field, value)
|
|
77
|
+
case field
|
|
78
|
+
when :tracked_at
|
|
79
|
+
value&.utc&.iso8601
|
|
80
|
+
when :provider, :model, :provider_response_id
|
|
81
|
+
csv_safe(value)
|
|
82
|
+
when :tags
|
|
83
|
+
csv_safe(csv_tags(value))
|
|
84
|
+
else
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def csv_tags(value)
|
|
90
|
+
return value.transform_keys(&:to_s).to_json if value.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
JSON.parse(value || "{}").to_json
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
"{}"
|
|
95
|
+
end
|
|
96
|
+
|
|
83
97
|
def csv_safe(value)
|
|
84
98
|
return value if value.nil?
|
|
85
99
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "llm_cost_tracker/storage/active_record_store"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Dashboard
|
|
5
7
|
OverviewStatsData = Data.define(
|
|
@@ -77,7 +79,7 @@ module LlmCostTracker
|
|
|
77
79
|
now = Time.now.utc
|
|
78
80
|
month_start = now.beginning_of_month
|
|
79
81
|
month_end = now.end_of_month
|
|
80
|
-
spent = LlmCostTracker::
|
|
82
|
+
spent = LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: now)
|
|
81
83
|
elapsed_seconds = now - month_start
|
|
82
84
|
total_seconds = month_end - month_start
|
|
83
85
|
projected_spent = if spent.zero? || !elapsed_seconds.positive?
|
|
@@ -42,11 +42,10 @@ module LlmCostTracker
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def build_sql
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
end
|
|
45
|
+
return postgresql_sql if ActiveRecordAdapter.postgresql?(connection)
|
|
46
|
+
return mysql_sql if ActiveRecordAdapter.mysql?(connection)
|
|
47
|
+
|
|
48
|
+
sqlite_sql
|
|
50
49
|
end
|
|
51
50
|
|
|
52
51
|
def mysql_sql
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ActiveRecordAdapter
|
|
5
|
+
MYSQL_ADAPTERS = %w[
|
|
6
|
+
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
|
|
7
|
+
ActiveRecord::ConnectionAdapters::Mysql2Adapter
|
|
8
|
+
ActiveRecord::ConnectionAdapters::TrilogyAdapter
|
|
9
|
+
].freeze
|
|
10
|
+
POSTGRESQL_ADAPTERS = %w[
|
|
11
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
12
|
+
].freeze
|
|
13
|
+
SQLITE_ADAPTERS = %w[
|
|
14
|
+
ActiveRecord::ConnectionAdapters::SQLite3Adapter
|
|
15
|
+
].freeze
|
|
16
|
+
MYSQL_PATTERN = /mysql|trilogy|mariadb/i
|
|
17
|
+
POSTGRESQL_PATTERN = /postgres/i
|
|
18
|
+
SQLITE_PATTERN = /sqlite/i
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def mysql?(value) = adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
|
|
22
|
+
|
|
23
|
+
def postgresql?(value)
|
|
24
|
+
adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sqlite?(value) = adapter_instance?(value, SQLITE_ADAPTERS) || adapter_name(value).match?(SQLITE_PATTERN)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def adapter_instance?(value, class_names)
|
|
32
|
+
class_names.any? do |class_name|
|
|
33
|
+
adapter_class = constantize(class_name)
|
|
34
|
+
adapter_class && value.is_a?(adapter_class)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def constantize(name)
|
|
39
|
+
name.split("::").reduce(Object) { |namespace, part| namespace.const_get(part, false) }
|
|
40
|
+
rescue NameError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def adapter_name(value)
|
|
45
|
+
value.respond_to?(:adapter_name) ? value.adapter_name.to_s : value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Doctor
|
|
7
|
+
class IngestionCheck
|
|
8
|
+
PENDING_AGE_WARNING_SECONDS = 60
|
|
9
|
+
|
|
10
|
+
def self.call(check_class)
|
|
11
|
+
new(check_class).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(check_class)
|
|
15
|
+
@check_class = check_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless active_record_storage? && llm_api_calls_table?
|
|
20
|
+
|
|
21
|
+
missing = missing_parts
|
|
22
|
+
if missing.empty?
|
|
23
|
+
quarantined = quarantined_count
|
|
24
|
+
if quarantined.positive?
|
|
25
|
+
return check_class.new(:warn, "durable ingestion", "#{quarantined} inbox events quarantined after retries")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
pending = pending_snapshot
|
|
29
|
+
if stale_pending?(pending)
|
|
30
|
+
return check_class.new(
|
|
31
|
+
:warn,
|
|
32
|
+
"durable ingestion",
|
|
33
|
+
"#{pending.fetch(:count)} inbox events pending; oldest pending age #{pending_age(pending).round}s"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return check_class.new(:ok, "durable ingestion", "inbox and ingestor lease tables available")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
check_class.new(
|
|
41
|
+
:warn,
|
|
42
|
+
"durable ingestion",
|
|
43
|
+
"missing #{missing.join(', ')}; run bin/rails generate llm_cost_tracker:add_ingestion && bin/rails db:migrate"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :check_class
|
|
50
|
+
|
|
51
|
+
def missing_parts
|
|
52
|
+
[
|
|
53
|
+
column_names("llm_api_calls").include?("event_id") ? nil : "llm_api_calls.event_id",
|
|
54
|
+
table_exists?("llm_cost_tracker_inbox_events") ? nil : "llm_cost_tracker_inbox_events",
|
|
55
|
+
table_exists?("llm_cost_tracker_ingestor_leases") ? nil : "llm_cost_tracker_ingestor_leases"
|
|
56
|
+
].compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
|
|
60
|
+
|
|
61
|
+
def llm_api_calls_table? = table_exists?("llm_api_calls")
|
|
62
|
+
|
|
63
|
+
def table_exists?(name)
|
|
64
|
+
LlmCostTracker::LlmApiCall.connection.data_source_exists?(name)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def column_names(table) = LlmCostTracker::LlmApiCall.connection.columns(table).map(&:name)
|
|
70
|
+
|
|
71
|
+
def quarantined_count
|
|
72
|
+
return 0 unless table_exists?("llm_cost_tracker_inbox_events")
|
|
73
|
+
|
|
74
|
+
LlmCostTracker::LlmApiCall.connection.select_value(quarantined_sql).to_i
|
|
75
|
+
rescue StandardError
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def quarantined_sql
|
|
80
|
+
table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
|
|
81
|
+
"SELECT COUNT(*) FROM #{table} WHERE attempts >= #{max_attempts}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending_snapshot
|
|
85
|
+
row = LlmCostTracker::LlmApiCall.connection.select_one(pending_sql) || {}
|
|
86
|
+
{
|
|
87
|
+
count: row.fetch("pending_count").to_i,
|
|
88
|
+
oldest_at: row["oldest_created_at"] && Time.parse(row.fetch("oldest_created_at").to_s).utc
|
|
89
|
+
}
|
|
90
|
+
rescue StandardError
|
|
91
|
+
{ count: 0, oldest_at: nil }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pending_sql
|
|
95
|
+
table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
|
|
96
|
+
"SELECT COUNT(*) AS pending_count, MIN(created_at) AS oldest_created_at " \
|
|
97
|
+
"FROM #{table} WHERE attempts < #{max_attempts}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stale_pending?(pending)
|
|
101
|
+
pending.fetch(:count).positive? &&
|
|
102
|
+
pending.fetch(:oldest_at) &&
|
|
103
|
+
pending_age(pending) >= PENDING_AGE_WARNING_SECONDS
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def pending_age(pending) = Time.now.utc - pending.fetch(:oldest_at)
|
|
107
|
+
|
|
108
|
+
def max_attempts
|
|
109
|
+
if defined?(LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS)
|
|
110
|
+
LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS
|
|
111
|
+
else
|
|
112
|
+
5
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "price_freshness"
|
|
4
4
|
require_relative "doctor/capture_check"
|
|
5
|
+
require_relative "doctor/ingestion_check"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
class Doctor
|
|
@@ -45,6 +46,7 @@ module LlmCostTracker
|
|
|
45
46
|
table_check,
|
|
46
47
|
column_check,
|
|
47
48
|
period_totals_check,
|
|
49
|
+
IngestionCheck.call(Check),
|
|
48
50
|
prices_check,
|
|
49
51
|
calls_check
|
|
50
52
|
].compact
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddIngestionGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add durable ActiveRecord ingestion"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_ingestion_to_llm_cost_tracker.rb.erb",
|
|
18
|
+
"db/migrate/add_ingestion_to_llm_cost_tracker.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class AddIngestionToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
add_column :llm_api_calls, :event_id, :string unless column_exists?(:llm_api_calls, :event_id)
|
|
4
|
+
add_index :llm_api_calls, :event_id, unique: true if column_exists?(:llm_api_calls, :event_id) &&
|
|
5
|
+
!index_exists?(:llm_api_calls, :event_id)
|
|
6
|
+
|
|
7
|
+
create_table :llm_cost_tracker_inbox_events do |t|
|
|
8
|
+
t.string :event_id, null: false
|
|
9
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
10
|
+
t.datetime :tracked_at, null: false
|
|
11
|
+
t.text :payload, null: false
|
|
12
|
+
t.datetime :locked_at
|
|
13
|
+
t.string :locked_by
|
|
14
|
+
t.integer :attempts, null: false, default: 0
|
|
15
|
+
t.text :last_error
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end unless table_exists?(:llm_cost_tracker_inbox_events)
|
|
19
|
+
|
|
20
|
+
create_table :llm_cost_tracker_ingestor_leases do |t|
|
|
21
|
+
t.string :name, null: false
|
|
22
|
+
t.string :locked_by
|
|
23
|
+
t.datetime :locked_until
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end unless table_exists?(:llm_cost_tracker_ingestor_leases)
|
|
27
|
+
|
|
28
|
+
add_index :llm_cost_tracker_inbox_events, :event_id, unique: true unless index_exists?(:llm_cost_tracker_inbox_events, :event_id)
|
|
29
|
+
add_index :llm_cost_tracker_inbox_events, :tracked_at unless index_exists?(:llm_cost_tracker_inbox_events, :tracked_at)
|
|
30
|
+
add_index :llm_cost_tracker_inbox_events, [:locked_at, :id] unless index_exists?(:llm_cost_tracker_inbox_events, [:locked_at, :id])
|
|
31
|
+
add_index :llm_cost_tracker_ingestor_leases, :name, unique: true unless index_exists?(:llm_cost_tracker_ingestor_leases, :name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def up
|
|
3
5
|
create_table :llm_cost_tracker_period_totals do |t|
|
|
@@ -73,10 +75,9 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
def day_bucket_sql
|
|
76
|
-
|
|
77
|
-
when /postgres/i
|
|
78
|
+
if postgresql?
|
|
78
79
|
"DATE_TRUNC('day', tracked_at)::date"
|
|
79
|
-
|
|
80
|
+
elsif mysql?
|
|
80
81
|
"DATE(tracked_at)"
|
|
81
82
|
else
|
|
82
83
|
"date(tracked_at)"
|
|
@@ -84,13 +85,20 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
def month_bucket_sql
|
|
87
|
-
|
|
88
|
-
when /postgres/i
|
|
88
|
+
if postgresql?
|
|
89
89
|
"DATE_TRUNC('month', tracked_at)::date"
|
|
90
|
-
|
|
90
|
+
elsif mysql?
|
|
91
91
|
"DATE_FORMAT(tracked_at, '%Y-%m-01')"
|
|
92
92
|
else
|
|
93
93
|
"strftime('%Y-%m-01', tracked_at)"
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
|
+
|
|
97
|
+
def postgresql?
|
|
98
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def mysql?
|
|
102
|
+
LlmCostTracker::ActiveRecordAdapter.mysql?(connection)
|
|
103
|
+
end
|
|
96
104
|
end
|
|
@@ -2,23 +2,19 @@ class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
2
2
|
def up
|
|
3
3
|
unless column_exists?(:llm_api_calls, :stream)
|
|
4
4
|
add_column :llm_api_calls, :stream, :boolean, null: false, default: false
|
|
5
|
-
add_index :llm_api_calls, :stream
|
|
6
5
|
end
|
|
7
6
|
|
|
8
7
|
unless column_exists?(:llm_api_calls, :usage_source)
|
|
9
8
|
add_column :llm_api_calls, :usage_source, :string
|
|
10
|
-
add_index :llm_api_calls, :usage_source
|
|
11
9
|
end
|
|
12
10
|
end
|
|
13
11
|
|
|
14
12
|
def down
|
|
15
13
|
if column_exists?(:llm_api_calls, :usage_source)
|
|
16
|
-
remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
|
|
17
14
|
remove_column :llm_api_calls, :usage_source
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
if column_exists?(:llm_api_calls, :stream)
|
|
21
|
-
remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
|
|
22
18
|
remove_column :llm_api_calls, :stream
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def change
|
|
3
5
|
create_table :llm_api_calls do |t|
|
|
6
|
+
t.string :event_id, null: false
|
|
4
7
|
t.string :provider, null: false
|
|
5
8
|
t.string :model, null: false
|
|
6
9
|
t.integer :input_tokens, null: false, default: 0
|
|
@@ -37,19 +40,43 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
37
40
|
t.timestamps
|
|
38
41
|
end
|
|
39
42
|
|
|
43
|
+
create_table :llm_cost_tracker_inbox_events do |t|
|
|
44
|
+
t.string :event_id, null: false
|
|
45
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
46
|
+
t.datetime :tracked_at, null: false
|
|
47
|
+
t.text :payload, null: false
|
|
48
|
+
t.datetime :locked_at
|
|
49
|
+
t.string :locked_by
|
|
50
|
+
t.integer :attempts, null: false, default: 0
|
|
51
|
+
t.text :last_error
|
|
52
|
+
|
|
53
|
+
t.timestamps
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
create_table :llm_cost_tracker_ingestor_leases do |t|
|
|
57
|
+
t.string :name, null: false
|
|
58
|
+
t.string :locked_by
|
|
59
|
+
t.datetime :locked_until
|
|
60
|
+
|
|
61
|
+
t.timestamps
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
add_index :llm_api_calls, :event_id, unique: true
|
|
40
65
|
add_index :llm_api_calls, :tracked_at
|
|
41
66
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
42
67
|
add_index :llm_api_calls, [:model, :tracked_at]
|
|
43
|
-
add_index :llm_api_calls, :stream
|
|
44
|
-
add_index :llm_api_calls, :usage_source
|
|
45
68
|
add_index :llm_api_calls, :provider_response_id
|
|
46
69
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
47
70
|
add_index :llm_cost_tracker_period_totals, [:period, :period_start], unique: true
|
|
71
|
+
add_index :llm_cost_tracker_inbox_events, :event_id, unique: true
|
|
72
|
+
add_index :llm_cost_tracker_inbox_events, :tracked_at
|
|
73
|
+
add_index :llm_cost_tracker_inbox_events, [:locked_at, :id]
|
|
74
|
+
add_index :llm_cost_tracker_ingestor_leases, :name, unique: true
|
|
48
75
|
end
|
|
49
76
|
|
|
50
77
|
private
|
|
51
78
|
|
|
52
79
|
def postgresql?
|
|
53
|
-
|
|
80
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
54
81
|
end
|
|
55
82
|
end
|
|
@@ -4,7 +4,7 @@ LlmCostTracker.configure do |config|
|
|
|
4
4
|
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
# :active_record
|
|
7
|
+
# :active_record captures events into a durable inbox and ingests them into llm_api_calls.
|
|
8
8
|
# Other options: :log for local logging, :custom for your own storage callable.
|
|
9
9
|
config.storage_backend = :active_record
|
|
10
10
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def up
|
|
3
5
|
unless postgresql?
|
|
@@ -32,7 +34,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
|
|
|
32
34
|
private
|
|
33
35
|
|
|
34
36
|
def postgresql?
|
|
35
|
-
|
|
37
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def tags_jsonb?
|
|
@@ -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 PeriodGrouping
|
|
5
7
|
PERIOD_FORMATS = {
|
|
@@ -34,10 +36,9 @@ module LlmCostTracker
|
|
|
34
36
|
column = period_column_expression(column)
|
|
35
37
|
formats = PERIOD_FORMATS.fetch(period)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
when /postgres/i
|
|
39
|
+
if ActiveRecordAdapter.postgresql?(connection)
|
|
39
40
|
postgres_period_expression(period, column, formats)
|
|
40
|
-
|
|
41
|
+
elsif ActiveRecordAdapter.mysql?(connection)
|
|
41
42
|
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
42
43
|
else
|
|
43
44
|
"strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
|