llm_cost_tracker 0.14.0 → 0.14.2
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 +128 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
- data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
- data/lib/llm_cost_tracker/integrations/base.rb +56 -19
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +14 -13
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
- data/lib/llm_cost_tracker/parsers.rb +12 -41
- data/lib/llm_cost_tracker/prices.json +3071 -249
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +17 -10
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +26 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +23 -10
|
@@ -6,19 +6,13 @@ require_relative "errors"
|
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
module Integrations
|
|
9
|
-
autoload :Base, "llm_cost_tracker/integrations/base"
|
|
10
|
-
|
|
11
9
|
Dir.glob(File.join(__dir__, "integrations", "*.rb")).each do |path|
|
|
12
10
|
basename = File.basename(path, ".rb")
|
|
13
|
-
next if basename == "base"
|
|
14
|
-
|
|
15
11
|
autoload basename.camelize.to_sym, "llm_cost_tracker/integrations/#{basename}"
|
|
16
12
|
end
|
|
17
13
|
|
|
18
14
|
def self.install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
19
|
-
|
|
20
|
-
warn_double_instrumentation(normalized)
|
|
21
|
-
normalized.each do |name|
|
|
15
|
+
normalize(names).each do |name|
|
|
22
16
|
integration = fetch(name)
|
|
23
17
|
next integration.install if integration
|
|
24
18
|
|
|
@@ -41,19 +35,6 @@ module LlmCostTracker
|
|
|
41
35
|
Array(names).flatten.uniq
|
|
42
36
|
end
|
|
43
37
|
|
|
44
|
-
def self.warn_double_instrumentation(names)
|
|
45
|
-
return unless names.include?(:ruby_llm)
|
|
46
|
-
|
|
47
|
-
overlapping = names - [:ruby_llm]
|
|
48
|
-
return if overlapping.empty?
|
|
49
|
-
|
|
50
|
-
Logging.warn(
|
|
51
|
-
":ruby_llm is enabled together with #{overlapping.map(&:inspect).join(', ')}. " \
|
|
52
|
-
"RubyLLM uses HTTP underneath, so calls routed to those providers may be recorded twice " \
|
|
53
|
-
"(once via the SDK patch, once via the Faraday parser). Pick one path per provider."
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
38
|
def self.fetch(name)
|
|
58
39
|
const_name = name.to_s.camelize
|
|
59
40
|
return nil unless const_name.match?(/\A[A-Z]\w*\z/)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Isolation
|
|
8
|
+
class << self
|
|
9
|
+
def guard(model = LlmCostTracker::Call, &)
|
|
10
|
+
connection = model.connection
|
|
11
|
+
nested = connection.transaction_open?
|
|
12
|
+
return yield unless nested
|
|
13
|
+
|
|
14
|
+
model.transaction(requires_new: true, &)
|
|
15
|
+
rescue ActiveRecord::TransactionRollbackError => e
|
|
16
|
+
raise unless nested && connection.savepoint_errors_invalidate_transactions?
|
|
17
|
+
|
|
18
|
+
raise TransactionAbortedError, e
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal/util"
|
|
4
4
|
|
|
5
|
+
require_relative "../isolation"
|
|
5
6
|
require_relative "../period"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
@@ -22,7 +23,7 @@ module LlmCostTracker
|
|
|
22
23
|
|
|
23
24
|
values = periods.to_h { |period| [period, BigDecimal("0")] }
|
|
24
25
|
period_by_name = periods.to_h { |period| [period.to_s, period] }
|
|
25
|
-
LlmCostTracker::Call.find_by_sql(union_sql).each do |row|
|
|
26
|
+
Isolation.guard { LlmCostTracker::Call.find_by_sql(union_sql) }.each do |row|
|
|
26
27
|
values[period_by_name.fetch(row.period_key)] = row.total_cost.to_d
|
|
27
28
|
end
|
|
28
29
|
values
|
|
@@ -38,25 +39,29 @@ module LlmCostTracker
|
|
|
38
39
|
|
|
39
40
|
def period_select(period)
|
|
40
41
|
start = Period.range_start(period, time)
|
|
41
|
-
components = ["(#{recorded_sql(
|
|
42
|
+
components = ["(#{recorded_sql(start)})"]
|
|
42
43
|
components << "(#{pending_sql(start)})" if Ingestion.async?
|
|
43
44
|
"SELECT #{quote(period.to_s)} AS period_key, #{components.join(' + ')} AS total_cost"
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
def recorded_sql(
|
|
47
|
-
|
|
48
|
-
return
|
|
47
|
+
def recorded_sql(start)
|
|
48
|
+
today = Period.range_start(:day, time)
|
|
49
|
+
return calls_sql(start) unless Rollups.cache_active? && start < today
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
"GREATEST(#{rollup}, #{calls})"
|
|
51
|
+
"#{completed_days_sql(start, today)} + #{calls_sql(today)}"
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def
|
|
55
|
-
"COALESCE(#{sum_sql(
|
|
54
|
+
def calls_sql(start)
|
|
55
|
+
"COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def completed_days_sql(start, today)
|
|
59
|
+
days = LlmCostTracker::CallRollup.where(period: "day", period_start: start.to_date...today.to_date)
|
|
60
|
+
"COALESCE(#{sum_sql(days)}, 0)"
|
|
56
61
|
end
|
|
57
62
|
|
|
58
|
-
def
|
|
59
|
-
|
|
63
|
+
def pending_sql(start)
|
|
64
|
+
"COALESCE(#{sum_sql(Ingestion::InboxEntry.pending.where(tracked_at: start..time))}, 0)"
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
def sum_sql(scope)
|
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal/util"
|
|
4
4
|
|
|
5
|
+
require_relative "isolation"
|
|
5
6
|
require_relative "period"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
8
9
|
module Ledger
|
|
9
10
|
module Rollups
|
|
11
|
+
SOURCE_COLUMNS = %i[tracked_at total_cost pricing_snapshot provider].freeze
|
|
12
|
+
|
|
10
13
|
class << self
|
|
11
14
|
def cache_active?
|
|
12
15
|
return false unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
13
|
-
|
|
16
|
+
|
|
17
|
+
rollup = LlmCostTracker::CallRollup
|
|
18
|
+
return true if rollup.table_exists?
|
|
19
|
+
|
|
20
|
+
rollup.connection.schema_cache.clear_data_source_cache!(rollup.table_name)
|
|
21
|
+
return true if rollup.table_exists?
|
|
14
22
|
|
|
15
23
|
warn_missing_table
|
|
16
24
|
false
|
|
@@ -19,10 +27,8 @@ module LlmCostTracker
|
|
|
19
27
|
def increment!(events)
|
|
20
28
|
return unless cache_active?
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
LlmCostTracker::CallRollup.increment_all(period_rows_for_events(events))
|
|
30
|
+
rows = rows_from_buckets(bucket_totals(Array(events)))
|
|
31
|
+
LlmCostTracker::CallRollup.increment_all(rows) if rows.any?
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
ROLLUP_INCREMENT_ATTEMPTS = 3
|
|
@@ -33,22 +39,29 @@ module LlmCostTracker
|
|
|
33
39
|
:REBUILD_INSERT_SLICE
|
|
34
40
|
|
|
35
41
|
def increment_safely!(events)
|
|
42
|
+
return unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
43
|
+
|
|
44
|
+
LlmCostTracker::Call.current_transaction.after_commit { increment_with_retries(events) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def increment_with_retries(events)
|
|
48
|
+
retryable = !LlmCostTracker::Call.connection.transaction_open?
|
|
36
49
|
attempt = 0
|
|
37
50
|
begin
|
|
38
51
|
attempt += 1
|
|
39
|
-
increment!(events)
|
|
52
|
+
Isolation.guard { increment!(events) }
|
|
53
|
+
rescue LlmCostTracker::Error
|
|
54
|
+
raise
|
|
40
55
|
rescue StandardError => e
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
|
|
56
|
+
if retryable && attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
|
|
44
57
|
sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
|
|
45
58
|
retry
|
|
46
59
|
end
|
|
47
60
|
|
|
48
61
|
LlmCostTracker::Logging.warn(
|
|
49
62
|
"Rollup increment failed for #{events.size} events after #{attempt} attempt(s): " \
|
|
50
|
-
"#{e.class}: #{e.message}.
|
|
51
|
-
"
|
|
63
|
+
"#{e.class}: #{e.message}. Monthly budget totals under-count these calls until you run " \
|
|
64
|
+
"bin/rails llm_cost_tracker:rebuild_rollups."
|
|
52
65
|
)
|
|
53
66
|
end
|
|
54
67
|
end
|
|
@@ -99,12 +112,7 @@ module LlmCostTracker
|
|
|
99
112
|
end
|
|
100
113
|
|
|
101
114
|
def priced_calls
|
|
102
|
-
LlmCostTracker::Call.where.not(total_cost: nil)
|
|
103
|
-
.select(:id, :total_cost, :pricing_snapshot, :provider, :tracked_at)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def period_rows_for_events(events)
|
|
107
|
-
rows_from_buckets(bucket_totals(events))
|
|
115
|
+
LlmCostTracker::Call.where.not(total_cost: nil).select(:id, *SOURCE_COLUMNS)
|
|
108
116
|
end
|
|
109
117
|
|
|
110
118
|
def rows_from_buckets(buckets)
|
|
@@ -8,8 +8,6 @@ module LlmCostTracker
|
|
|
8
8
|
module Adapter
|
|
9
9
|
MYSQL_ADAPTERS = %w[
|
|
10
10
|
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
|
|
11
|
-
ActiveRecord::ConnectionAdapters::Mysql2Adapter
|
|
12
|
-
ActiveRecord::ConnectionAdapters::TrilogyAdapter
|
|
13
11
|
].freeze
|
|
14
12
|
POSTGRESQL_ADAPTERS = %w[
|
|
15
13
|
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
@@ -36,11 +34,16 @@ module LlmCostTracker
|
|
|
36
34
|
MYSQL_PERIOD_FORMATS = { day: "%Y-%m-%d", month: "%Y-%m" }.freeze
|
|
37
35
|
private_constant :PG_PERIOD_FORMATS, :MYSQL_PERIOD_FORMATS
|
|
38
36
|
|
|
39
|
-
def period_bucket_sql(connection, period, column)
|
|
37
|
+
def period_bucket_sql(connection, period, column, time_zone: nil)
|
|
40
38
|
period = period.to_sym
|
|
39
|
+
zone = time_zone&.tzinfo&.name
|
|
41
40
|
if postgresql?(connection)
|
|
41
|
+
if zone && postgresql_zone?(connection, zone)
|
|
42
|
+
column = "(#{column}::timestamp AT TIME ZONE 'UTC') AT TIME ZONE '#{zone}'"
|
|
43
|
+
end
|
|
42
44
|
"TO_CHAR(DATE_TRUNC('#{period}', #{column}), '#{PG_PERIOD_FORMATS.fetch(period)}')"
|
|
43
45
|
elsif mysql?(connection)
|
|
46
|
+
column = "COALESCE(CONVERT_TZ(#{column}, '+00:00', '#{zone}'), #{column})" if zone
|
|
44
47
|
"DATE_FORMAT(#{column}, '#{MYSQL_PERIOD_FORMATS.fetch(period)}')"
|
|
45
48
|
else
|
|
46
49
|
ensure_supported!(connection)
|
|
@@ -51,6 +54,14 @@ module LlmCostTracker
|
|
|
51
54
|
|
|
52
55
|
private
|
|
53
56
|
|
|
57
|
+
def postgresql_zone?(connection, zone)
|
|
58
|
+
@postgresql_zones ||= {}
|
|
59
|
+
@postgresql_zones.fetch(zone) do
|
|
60
|
+
sql = "SELECT 1 FROM pg_timezone_names WHERE name = #{connection.quote(zone)}"
|
|
61
|
+
@postgresql_zones[zone] = !connection.select_value(sql).nil?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def adapter_instance?(value, class_names)
|
|
55
66
|
class_names.any? do |class_name|
|
|
56
67
|
adapter_class = class_name.safe_constantize
|
|
@@ -21,10 +21,11 @@ module LlmCostTracker
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def current_schema_errors
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
Adapter.ensure_supported!(model.connection)
|
|
25
|
+
unless model.table_exists?
|
|
26
|
+
model.connection.schema_cache.clear_data_source_cache!(model.table_name)
|
|
27
|
+
return ["#{model.table_name} table is missing"] unless model.table_exists?
|
|
28
|
+
end
|
|
28
29
|
|
|
29
30
|
columns_hash = model.columns_hash
|
|
30
31
|
cache = @schema_capabilities
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ledger
|
|
5
|
+
module Storable
|
|
6
|
+
def self.clean(value)
|
|
7
|
+
case value
|
|
8
|
+
when Hash then value.to_h { |key, nested| [clean(key), clean(nested)] }
|
|
9
|
+
when Array then value.map { |nested| clean(nested) }
|
|
10
|
+
when String then value.dup.force_encoding(Encoding::UTF_8).scrub.delete("\u0000")
|
|
11
|
+
else value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "../pricing"
|
|
|
6
6
|
require_relative "rollups"
|
|
7
7
|
require_relative "../budget/per_tag"
|
|
8
8
|
require_relative "tags/encoding"
|
|
9
|
+
require_relative "storable"
|
|
9
10
|
|
|
10
11
|
module LlmCostTracker
|
|
11
12
|
module Ledger
|
|
@@ -23,11 +24,13 @@ module LlmCostTracker
|
|
|
23
24
|
events = Array(events)
|
|
24
25
|
return if events.empty?
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
Isolation.guard do
|
|
28
|
+
LlmCostTracker::Call.transaction do
|
|
29
|
+
rows = events.map { |event| attributes_for(event) }
|
|
30
|
+
call_ids = insert_calls_returning_ids(rows, events)
|
|
31
|
+
insert_line_items(events, call_ids)
|
|
32
|
+
insert_call_tags(events, call_ids)
|
|
33
|
+
end
|
|
31
34
|
end
|
|
32
35
|
end
|
|
33
36
|
|
|
@@ -62,9 +65,7 @@ module LlmCostTracker
|
|
|
62
65
|
pricing_snapshot: event.pricing_snapshot
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
attributes
|
|
66
|
-
.merge(event.token_usage.to_h)
|
|
67
|
-
.merge(total_cost: event.cost&.total)
|
|
68
|
+
Storable.clean(attributes.merge(event.token_usage.to_h).merge(total_cost: event.cost&.total))
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def call_ids_for(events)
|
|
@@ -90,7 +91,7 @@ module LlmCostTracker
|
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
def line_item_attributes(call_id:, line_item:, position:)
|
|
93
|
-
|
|
94
|
+
Storable.clean(
|
|
94
95
|
llm_cost_tracker_call_id: call_id,
|
|
95
96
|
position: position,
|
|
96
97
|
kind: line_item.kind,
|
|
@@ -112,16 +113,16 @@ module LlmCostTracker
|
|
|
112
113
|
provider_item_id: line_item.provider_item_id,
|
|
113
114
|
details: stored_details(line_item.details),
|
|
114
115
|
created_at: Time.now.utc
|
|
115
|
-
|
|
116
|
+
)
|
|
116
117
|
end
|
|
117
118
|
|
|
118
119
|
def insert_call_tags(events, call_ids)
|
|
119
120
|
rows = events.flat_map do |event|
|
|
120
|
-
(event.tags || {}).map do |key, value|
|
|
121
|
+
(event.tags || {}).to_h { |key, value| [key.to_s, value] }.map do |key, value|
|
|
121
122
|
{
|
|
122
123
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
123
|
-
key: key
|
|
124
|
-
value: Tags::Encoding.encode(value)
|
|
124
|
+
key: key,
|
|
125
|
+
value: Tags::Encoding.encode(Storable.clean(value))
|
|
125
126
|
}.merge(budget_columns_for(event))
|
|
126
127
|
end
|
|
127
128
|
end
|
|
@@ -19,7 +19,7 @@ module LlmCostTracker
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def value_arel
|
|
22
|
-
Arel.sql(
|
|
22
|
+
Arel.sql(raw_value_sql(LlmCostTracker::CallTag.connection))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def label_sql(connection)
|
|
@@ -35,10 +35,6 @@ module LlmCostTracker
|
|
|
35
35
|
def call_tag_table
|
|
36
36
|
LlmCostTracker::CallTag.quoted_table_name
|
|
37
37
|
end
|
|
38
|
-
|
|
39
|
-
def quote_column(name)
|
|
40
|
-
LlmCostTracker::CallTag.connection.quote_column_name(name)
|
|
41
|
-
end
|
|
42
38
|
end
|
|
43
39
|
end
|
|
44
40
|
end
|
|
@@ -10,8 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
def self.encode(value)
|
|
12
12
|
encoded = case value
|
|
13
|
-
when Hash then JSON.generate(
|
|
14
|
-
when Array then JSON.generate(normalize_array(value))
|
|
13
|
+
when Hash, Array then JSON.generate(normalize_value(value))
|
|
15
14
|
else value.to_s
|
|
16
15
|
end
|
|
17
16
|
truncate(encoded)
|
|
@@ -24,18 +23,10 @@ module LlmCostTracker
|
|
|
24
23
|
string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def self.normalize_hash(hash)
|
|
28
|
-
hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def self.normalize_array(array)
|
|
32
|
-
array.map { |v| normalize_value(v) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
26
|
def self.normalize_value(value)
|
|
36
27
|
case value
|
|
37
|
-
when Hash then
|
|
38
|
-
when Array then
|
|
28
|
+
when Hash then value.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
29
|
+
when Array then value.map { |v| normalize_value(v) }
|
|
39
30
|
else value.to_s
|
|
40
31
|
end
|
|
41
32
|
end
|
|
@@ -9,8 +9,6 @@ module LlmCostTracker
|
|
|
9
9
|
class << self
|
|
10
10
|
def apply(tags)
|
|
11
11
|
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
|
|
12
|
-
return LlmCostTracker::Call.all if normalized_tags.empty?
|
|
13
|
-
|
|
14
12
|
normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
|
|
15
13
|
relation.where(id: LlmCostTracker::CallTag.where(key: key,
|
|
16
14
|
value: value).select(:llm_cost_tracker_call_id))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "redaction"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Logging
|
|
5
7
|
class << self
|
|
@@ -9,7 +11,7 @@ module LlmCostTracker
|
|
|
9
11
|
|
|
10
12
|
private
|
|
11
13
|
|
|
12
|
-
def prefixed(message) = "[#{LlmCostTracker.name}] #{message}"
|
|
14
|
+
def prefixed(message) = "[#{LlmCostTracker.name}] #{Redaction.text(message)}"
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
end
|