llm_cost_tracker 0.13.0 → 0.14.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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
@@ -128,10 +128,16 @@ module LlmCostTracker
128
128
  def each(&block)
129
129
  return enum_for(:each) unless block
130
130
 
131
+ deferred = nil
131
132
  @raw_stream.each do |response|
132
- LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
133
+ begin
134
+ LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
135
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
136
+ deferred ||= e
137
+ end
133
138
  block.call(response)
134
139
  end
140
+ raise deferred if deferred
135
141
  end
136
142
 
137
143
  def respond_to_missing?(name, include_private = false)
@@ -27,6 +27,7 @@ module LlmCostTracker
27
27
 
28
28
  def install
29
29
  validate_contract!
30
+ Logging.warn(untested_version_message) if untested_version?
30
31
  patch_targets.each do |target|
31
32
  target_class = target.fetch(:constant_name).to_s.safe_constantize
32
33
  install_patch(target_class, target.fetch(:patch)) if target_class
@@ -39,6 +40,7 @@ module LlmCostTracker
39
40
  if problems.any?
40
41
  return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
41
42
  end
43
+ return Check.new(:warn, name, untested_version_message) if untested_version?
42
44
 
43
45
  installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
44
46
  target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
@@ -60,7 +62,7 @@ module LlmCostTracker
60
62
 
61
63
  def record_safely
62
64
  yield
63
- rescue LlmCostTracker::Error
65
+ rescue *LlmCostTracker::CALLER_ERRORS
64
66
  raise
65
67
  rescue StandardError => e
66
68
  Logging.warn("#{integration_name} integration failed to record usage: #{e.class}: #{e.message}")
@@ -124,6 +126,11 @@ module LlmCostTracker
124
126
  @minimum_version
125
127
  end
126
128
 
129
+ def maximum_version(value = nil)
130
+ @maximum_version = value if value
131
+ @maximum_version
132
+ end
133
+
127
134
  def gem_version
128
135
  Gem.loaded_specs[integration_name.to_s]&.version
129
136
  end
@@ -160,6 +167,15 @@ module LlmCostTracker
160
167
  ["#{name} >= #{minimum_version} is required, detected #{version}"]
161
168
  end
162
169
 
170
+ def untested_version?
171
+ maximum_version && gem_version && gem_version >= Gem::Version.new(maximum_version)
172
+ end
173
+
174
+ def untested_version_message
175
+ "#{integration_name} #{gem_version} is newer than the tested range (< #{maximum_version}); " \
176
+ "its calls may not be recorded"
177
+ end
178
+
163
179
  def target_problems
164
180
  patch_targets.flat_map do |target|
165
181
  constant_name = target.fetch(:constant_name)
@@ -13,36 +13,36 @@ module LlmCostTracker
13
13
  class << self
14
14
  def maybe_capture(batch, resource:)
15
15
  return unless Openai.active?
16
- return unless batch.respond_to?(:status) && batch.status.to_s == "completed"
17
-
18
- output_file_id = batch.respond_to?(:output_file_id) ? batch.output_file_id : nil
19
- return unless output_file_id
20
-
21
- batch_id = batch.respond_to?(:id) ? batch.id : nil
22
- return unless batch_id && claim(batch_id)
16
+ return unless batch.status.to_s == "completed"
17
+ return unless batch.output_file_id && batch.id
18
+ return if captured?(batch.id)
23
19
 
24
20
  client = resource.instance_variable_get(:@client)
25
21
  host = Openai.client_host_for(resource)
26
22
  Openai.record_safely do
27
- io = client.files.content(output_file_id)
28
- capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
23
+ io = client.files.content(batch.output_file_id)
24
+ deferred = capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
25
+ mark_captured(batch.id)
26
+ raise deferred if deferred
29
27
  end
30
28
  end
31
29
 
32
30
  private
33
31
 
34
- def claim(batch_id)
32
+ def captured?(batch_id)
33
+ MUTEX.synchronize { @dedup&.include?(batch_id) || false }
34
+ end
35
+
36
+ def mark_captured(batch_id)
35
37
  MUTEX.synchronize do
36
38
  @dedup ||= Set.new
37
- next false if @dedup.include?(batch_id)
38
-
39
39
  @dedup.clear if @dedup.size >= DEDUP_LIMIT
40
40
  @dedup.add(batch_id)
41
- true
42
41
  end
43
42
  end
44
43
 
45
44
  def capture_jsonl(jsonl, host:)
45
+ deferred = nil
46
46
  jsonl.each_line do |line|
47
47
  line = line.strip
48
48
  next if line.empty?
@@ -54,7 +54,10 @@ module LlmCostTracker
54
54
  next unless response.is_a?(Hash) && response["usage"]
55
55
 
56
56
  record_result(response, host: host)
57
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
58
+ deferred ||= e
57
59
  end
60
+ deferred
58
61
  end
59
62
 
60
63
  def parse_line(line)
@@ -8,10 +8,14 @@ module LlmCostTracker
8
8
  extend Base
9
9
 
10
10
  minimum_version "1.15.0"
11
+ maximum_version "3.0.0"
11
12
 
12
13
  class << self
13
14
  def patch_targets
14
- [patch_target("RubyLLM::Provider", with: ProviderPatch)]
15
+ [
16
+ patch_target("RubyLLM::Provider", with: ProviderPatch),
17
+ patch_target("RubyLLM::Providers::Gemini::Transcription", with: GeminiTranscriptionPatch, optional: true)
18
+ ]
15
19
  end
16
20
 
17
21
  def record_completion(provider, response, request:, latency_ms:, has_block:)
@@ -36,25 +40,29 @@ module LlmCostTracker
36
40
  end
37
41
 
38
42
  def record_transcription(provider, response, request:, latency_ms:)
43
+ model = response_model_id(response) || model_id_from_request(request[:model])
44
+ match = LlmCostTracker::Pricing::Matcher.lookup(provider: provider.slug.to_s, model: model)
39
45
  record_usage(
40
46
  provider: provider.slug.to_s,
41
- model: response_model_id(response) || model_id_from_request(request[:model]),
47
+ model: model,
42
48
  response: response,
43
49
  latency_ms: latency_ms,
44
- stream: false
50
+ stream: false,
51
+ audio_input: match&.prices&.key?("audio_input")
45
52
  )
46
53
  end
47
54
 
48
55
  def record_image(provider, response, request:, latency_ms:)
49
- usage = response.usage.with_indifferent_access
56
+ image = response.is_a?(Array) ? response.first : response
57
+ usage = image_usage(image)
50
58
  raw_input = usage[:input_tokens].to_i
51
59
  raw_output = usage[:output_tokens].to_i
52
60
  image_input = image_token_detail(usage, :input)
53
61
  image_output = image_token_detail(usage, :output)
54
62
  record_passthrough(
55
63
  provider: provider.slug.to_s,
56
- model: response_model_id(response) || model_id_from_request(request[:model]),
57
- response: response,
64
+ model: response_model_id(image) || model_id_from_request(request[:model]),
65
+ response: image,
58
66
  latency_ms: latency_ms,
59
67
  input_tokens: [raw_input - image_input, 0].max,
60
68
  image_input_tokens: image_input,
@@ -74,6 +82,12 @@ module LlmCostTracker
74
82
  )
75
83
  end
76
84
 
85
+ def image_usage(image)
86
+ usage = image.try(:usage)
87
+ usage = image.send(:raw_usage) if !usage.is_a?(Hash) && image.respond_to?(:raw_usage, true)
88
+ (usage.is_a?(Hash) ? usage : {}).with_indifferent_access
89
+ end
90
+
77
91
  def image_token_detail(usage, direction)
78
92
  container_key = direction == :input ? :input_tokens_details : :output_tokens_details
79
93
  details = usage[container_key]
@@ -111,27 +125,29 @@ module LlmCostTracker
111
125
  end
112
126
  end
113
127
 
114
- def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil)
128
+ def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil, audio_input: false)
115
129
  return unless active?
116
130
 
117
131
  record_safely do
118
- input_tokens = response.input_tokens
119
- output_tokens = response.output_tokens if output_tokens.nil?
132
+ counts = token_counts(response)
133
+ input_tokens = counts[:input]
134
+ output_tokens = counts[:output] if output_tokens.nil?
120
135
  next if input_tokens.nil? && output_tokens.nil?
121
136
 
122
- cache_write_5m, cache_write_1h = cache_creation_split(provider, response)
137
+ cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write])
123
138
  LlmCostTracker::Tracker.record(
124
139
  event: Event.build(
125
140
  provider: provider,
126
141
  model: model,
127
142
  pricing_mode: pricing_mode_for(provider: provider, response: response),
128
143
  token_usage: Usage::TokenUsage.build(
129
- input_tokens: input_tokens.to_i,
144
+ input_tokens: audio_input ? 0 : input_tokens.to_i,
145
+ audio_input_tokens: audio_input ? input_tokens.to_i : 0,
130
146
  output_tokens: output_tokens.to_i,
131
- cache_read_input_tokens: response.try(:cached_tokens).to_i,
147
+ cache_read_input_tokens: counts[:cache_read].to_i,
132
148
  cache_write_input_tokens: cache_write_5m,
133
149
  cache_write_extended_input_tokens: cache_write_1h,
134
- hidden_output_tokens: response.try(:thinking_tokens).to_i
150
+ hidden_output_tokens: counts[:thinking].to_i
135
151
  ),
136
152
  stream: stream,
137
153
  usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
@@ -142,11 +158,22 @@ module LlmCostTracker
142
158
  end
143
159
  end
144
160
 
145
- def cache_creation_split(provider, response)
146
- return [response.try(:cache_creation_tokens).to_i, 0] unless provider == "anthropic"
161
+ def token_counts(response)
162
+ tokens = response.try(:tokens)
163
+ return { input: response.try(:input_tokens), output: response.try(:output_tokens) } unless tokens
164
+
165
+ {
166
+ input: tokens.input,
167
+ output: tokens.output,
168
+ cache_read: tokens.cache_read,
169
+ cache_write: tokens.cache_write,
170
+ thinking: tokens.thinking
171
+ }
172
+ end
147
173
 
148
- cache = raw_body(response).dig("usage", "cache_creation")
149
- return [response.try(:cache_creation_tokens).to_i, 0] unless cache.is_a?(Hash)
174
+ def cache_write_split(provider, response, cache_write)
175
+ cache = raw_body(response).dig("usage", "cache_creation") if provider == "anthropic"
176
+ return [cache_write.to_i, 0] unless cache.is_a?(Hash)
150
177
 
151
178
  [cache["ephemeral_5m_input_tokens"].to_i, cache["ephemeral_1h_input_tokens"].to_i]
152
179
  end
@@ -164,7 +191,8 @@ module LlmCostTracker
164
191
  end
165
192
 
166
193
  def raw_body(response)
167
- body = response.try(:raw)&.body
194
+ raw = response.try(:raw)
195
+ body = raw.respond_to?(:body) ? raw.body : raw
168
196
  body.is_a?(Hash) ? body : {}
169
197
  end
170
198
 
@@ -217,6 +245,13 @@ module LlmCostTracker
217
245
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
218
246
  end
219
247
  end
248
+
249
+ module GeminiTranscriptionPatch
250
+ def transcribe(*args, **kwargs)
251
+ seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription)
252
+ LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
253
+ end
254
+ end
220
255
  end
221
256
  end
222
257
  end
@@ -0,0 +1,30 @@
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
+
21
+ def after_commit(&)
22
+ current = LlmCostTracker::Call.connection.current_transaction
23
+ return yield unless current.open? && current.joinable? && current.respond_to?(:after_commit)
24
+
25
+ current.after_commit(&)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ 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
@@ -45,7 +46,7 @@ module LlmCostTracker
45
46
 
46
47
  def recorded_sql(period, start)
47
48
  calls = "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
48
- return calls unless LlmCostTracker.configuration.cache_rollups
49
+ return calls unless Rollups.cache_active?
49
50
 
50
51
  rollup = "COALESCE(#{sum_sql(rollup_scope(period))}, 0)"
51
52
  "GREATEST(#{rollup}, #{calls})"
@@ -2,13 +2,24 @@
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
10
11
  class << self
12
+ def cache_active?
13
+ return false unless LlmCostTracker.configuration.budgets.totals_source == :cache
14
+ return true if LlmCostTracker::CallRollup.table_exists?
15
+
16
+ warn_missing_table
17
+ false
18
+ end
19
+
11
20
  def increment!(events)
21
+ return unless cache_active?
22
+
12
23
  events = Array(events).select(&:total_cost)
13
24
  return if events.empty?
14
25
 
@@ -18,29 +29,45 @@ module LlmCostTracker
18
29
  ROLLUP_INCREMENT_ATTEMPTS = 3
19
30
  ROLLUP_INCREMENT_BASE_DELAY_SECONDS = 0.05
20
31
  REBUILD_INSERT_SLICE = 1_000
21
- private_constant :ROLLUP_INCREMENT_ATTEMPTS, :ROLLUP_INCREMENT_BASE_DELAY_SECONDS, :REBUILD_INSERT_SLICE
32
+ private_constant :ROLLUP_INCREMENT_ATTEMPTS,
33
+ :ROLLUP_INCREMENT_BASE_DELAY_SECONDS,
34
+ :REBUILD_INSERT_SLICE
22
35
 
23
36
  def increment_safely!(events)
37
+ return unless LlmCostTracker.configuration.budgets.totals_source == :cache
38
+
39
+ Isolation.after_commit { increment_with_retries(events) }
40
+ end
41
+
42
+ def increment_with_retries(events)
43
+ retryable = !LlmCostTracker::Call.connection.transaction_open?
24
44
  attempt = 0
25
45
  begin
26
46
  attempt += 1
27
- increment!(events)
47
+ Isolation.guard { increment!(events) }
48
+ rescue LlmCostTracker::Error
49
+ raise
28
50
  rescue StandardError => e
29
- raise if LlmCostTracker::Call.connection.open_transactions.positive?
30
-
31
- if attempt < ROLLUP_INCREMENT_ATTEMPTS
51
+ if retryable && attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
32
52
  sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
33
53
  retry
34
54
  end
35
55
 
36
56
  LlmCostTracker::Logging.warn(
37
- "Rollup increment failed for #{events.size} events after #{attempt} attempts: " \
38
- "#{e.class}: #{e.message}"
57
+ "Rollup increment failed for #{events.size} events after #{attempt} attempt(s): " \
58
+ "#{e.class}: #{e.message}. Budget reads fall back to the calls ledger; " \
59
+ "run bin/rails llm_cost_tracker:rebuild_rollups to resync the cache."
39
60
  )
40
61
  end
41
62
  end
42
63
 
64
+ def rolled_back?(error)
65
+ error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout)
66
+ end
67
+
43
68
  def decrement!(records)
69
+ return unless cache_active?
70
+
44
71
  buckets = bucket_totals(records)
45
72
  return if buckets.empty?
46
73
 
@@ -60,6 +87,17 @@ module LlmCostTracker
60
87
 
61
88
  private
62
89
 
90
+ def warn_missing_table
91
+ return if @missing_table_warned
92
+
93
+ @missing_table_warned = true
94
+ Logging.warn(
95
+ "config.budgets.totals_source = :cache is set but llm_cost_tracker_call_rollups is missing; " \
96
+ "budget and dashboard totals fall back to aggregating llm_cost_tracker_calls. " \
97
+ "Run the call_rollups generator and migrate, or set config.budgets.totals_source = :ledger."
98
+ )
99
+ end
100
+
63
101
  def accumulate_priced_calls
64
102
  accumulated = Hash.new { |totals, key| totals[key] = BigDecimal("0") }
65
103
  priced_calls.in_batches do |batch|
@@ -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
@@ -4,7 +4,9 @@ require "json"
4
4
 
5
5
  require_relative "../pricing"
6
6
  require_relative "rollups"
7
+ require_relative "../budget/per_tag"
7
8
  require_relative "tags/encoding"
9
+ require_relative "storable"
8
10
 
9
11
  module LlmCostTracker
10
12
  module Ledger
@@ -15,18 +17,20 @@ module LlmCostTracker
15
17
  return if events.empty?
16
18
 
17
19
  persist_records(events)
18
- Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
20
+ Ledger::Rollups.increment_safely!(events)
19
21
  end
20
22
 
21
23
  def persist_records(events)
22
24
  events = Array(events)
23
25
  return if events.empty?
24
26
 
25
- LlmCostTracker::Call.transaction do
26
- rows = events.map { |event| attributes_for(event) }
27
- call_ids = insert_calls_returning_ids(rows, events)
28
- insert_line_items(events, call_ids)
29
- 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
30
34
  end
31
35
  end
32
36
 
@@ -61,9 +65,7 @@ module LlmCostTracker
61
65
  pricing_snapshot: event.pricing_snapshot
62
66
  }
63
67
 
64
- attributes
65
- .merge(event.token_usage.to_h)
66
- .merge(total_cost: event.cost&.total)
68
+ Storable.clean(attributes.merge(event.token_usage.to_h).merge(total_cost: event.cost&.total))
67
69
  end
68
70
 
69
71
  def call_ids_for(events)
@@ -89,7 +91,7 @@ module LlmCostTracker
89
91
  end
90
92
 
91
93
  def line_item_attributes(call_id:, line_item:, position:)
92
- {
94
+ Storable.clean(
93
95
  llm_cost_tracker_call_id: call_id,
94
96
  position: position,
95
97
  kind: line_item.kind,
@@ -111,7 +113,7 @@ module LlmCostTracker
111
113
  provider_item_id: line_item.provider_item_id,
112
114
  details: stored_details(line_item.details),
113
115
  created_at: Time.now.utc
114
- }
116
+ )
115
117
  end
116
118
 
117
119
  def insert_call_tags(events, call_ids)
@@ -120,8 +122,8 @@ module LlmCostTracker
120
122
  {
121
123
  llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
122
124
  key: key.to_s,
123
- value: Tags::Encoding.encode(value)
124
- }
125
+ value: Tags::Encoding.encode(Storable.clean(value))
126
+ }.merge(budget_columns_for(event))
125
127
  end
126
128
  end
127
129
  return if rows.empty?
@@ -129,6 +131,12 @@ module LlmCostTracker
129
131
  LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
130
132
  end
131
133
 
134
+ def budget_columns_for(event)
135
+ return {} unless LlmCostTracker::Budget::PerTag.columns?
136
+
137
+ { total_cost: event.cost&.total, tracked_at: event.tracked_at }
138
+ end
139
+
132
140
  def stored_details(details)
133
141
  (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
134
142
  end
@@ -6,12 +6,22 @@ module LlmCostTracker
6
6
  module Ledger
7
7
  module Tags
8
8
  module Encoding
9
+ INDEXABLE_BYTES = 2_048
10
+
9
11
  def self.encode(value)
10
- case value
11
- when Hash then JSON.generate(normalize_hash(value))
12
- when Array then JSON.generate(normalize_array(value))
13
- else value.to_s
14
- end
12
+ encoded = case value
13
+ when Hash then JSON.generate(normalize_hash(value))
14
+ when Array then JSON.generate(normalize_array(value))
15
+ else value.to_s
16
+ end
17
+ truncate(encoded)
18
+ end
19
+
20
+ def self.truncate(string)
21
+ limit = [LlmCostTracker.configuration.tags.max_value_bytesize.to_i, INDEXABLE_BYTES].min
22
+ return string if string.bytesize <= limit
23
+
24
+ string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
15
25
  end
16
26
 
17
27
  def self.normalize_hash(hash)
@@ -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,17 +1,17 @@
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
6
- def debug(message) = tagged.debug(message)
8
+ def debug(message) = Rails.logger.debug(prefixed(message))
7
9
 
8
- def warn(message) = tagged.warn(message)
10
+ def warn(message) = Rails.logger.warn(prefixed(message))
9
11
 
10
12
  private
11
13
 
12
- def tagged
13
- Rails.logger.tagged(LlmCostTracker.name)
14
- end
14
+ def prefixed(message) = "[#{LlmCostTracker.name}] #{Redaction.text(message)}"
15
15
  end
16
16
  end
17
17
  end