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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. 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
- normalized = normalize(names)
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(period, start)})"]
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(period, start)
47
- calls = "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
48
- return calls unless Rollups.cache_active?
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
- rollup = "COALESCE(#{sum_sql(rollup_scope(period))}, 0)"
51
- "GREATEST(#{rollup}, #{calls})"
51
+ "#{completed_days_sql(start, today)} + #{calls_sql(today)}"
52
52
  end
53
53
 
54
- def pending_sql(start)
55
- "COALESCE(#{sum_sql(Ingestion::InboxEntry.pending.where(tracked_at: start..time))}, 0)"
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 rollup_scope(period)
59
- LlmCostTracker::CallRollup.where(period: period.to_s, period_start: Period.bucket(period, time))
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
- return true if LlmCostTracker::CallRollup.table_exists?
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
- events = Array(events).select(&:total_cost)
23
- return if events.empty?
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
- raise if LlmCostTracker::Call.connection.open_transactions.positive?
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}. Budget reads fall back to the calls ledger; " \
51
- "run bin/rails llm_cost_tracker:rebuild_rollups to resync the cache."
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
- connection = model.connection
25
- Adapter.ensure_supported!(connection)
26
- table_name = model.table_name
27
- return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
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
- LlmCostTracker::Call.transaction do
27
- rows = events.map { |event| attributes_for(event) }
28
- call_ids = insert_calls_returning_ids(rows, events)
29
- insert_line_items(events, call_ids)
30
- insert_call_tags(events, call_ids)
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.to_s,
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("#{call_tag_table}.#{quote_column('value')}")
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(normalize_hash(value))
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 normalize_hash(value)
38
- when Array then normalize_array(value)
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,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "ledger/isolation"
3
4
  require_relative "ledger/schema"
4
5
  require_relative "ledger/period"
5
6
  require_relative "ledger/rollups"
@@ -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