llm_cost_tracker 0.14.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +1 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  5. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  6. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  9. data/app/models/llm_cost_tracker/call.rb +1 -1
  10. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  11. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  12. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  13. data/config/routes.rb +6 -1
  14. data/lib/llm_cost_tracker/budget/per_tag.rb +8 -4
  15. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  16. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  17. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  18. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  19. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  20. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -0
  21. data/lib/llm_cost_tracker/errors.rb +13 -0
  22. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -6
  23. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  24. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  25. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  26. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +6 -1
  27. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  28. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  29. data/lib/llm_cost_tracker/ledger/period/totals.rb +2 -1
  30. data/lib/llm_cost_tracker/ledger/rollups.rb +12 -4
  31. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  32. data/lib/llm_cost_tracker/ledger/store.rb +12 -11
  33. data/lib/llm_cost_tracker/ledger.rb +1 -0
  34. data/lib/llm_cost_tracker/logging.rb +3 -1
  35. data/lib/llm_cost_tracker/middleware/faraday.rb +32 -43
  36. data/lib/llm_cost_tracker/parsers.rb +5 -1
  37. data/lib/llm_cost_tracker/prices.json +878 -139
  38. data/lib/llm_cost_tracker/pricing/registry.rb +12 -1
  39. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  40. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  41. data/lib/llm_cost_tracker/pricing/sync.rb +30 -13
  42. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  43. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  44. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -1
  45. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +10 -5
  46. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +4 -0
  47. data/lib/llm_cost_tracker/redaction.rb +32 -0
  48. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  49. data/lib/llm_cost_tracker/tracker.rb +3 -4
  50. data/lib/llm_cost_tracker/version.rb +1 -1
  51. data/lib/llm_cost_tracker.rb +4 -2
  52. data/lib/tasks/llm_cost_tracker.rake +6 -4
  53. metadata +16 -4
@@ -11,52 +11,83 @@ module LlmCostTracker
11
11
 
12
12
  class << self
13
13
  def parse(body)
14
- return [] if body.blank?
14
+ events = []
15
+ reader = Reader.new { |event| events << event }
16
+ reader << body
17
+ reader.finish
18
+ events
19
+ end
20
+ end
15
21
 
16
- return parse_json_array(body) if body.match?(/\A\s*\[/)
22
+ class Reader
23
+ def initialize(&on_event)
24
+ @on_event = on_event
25
+ @pending = String.new(encoding: Encoding::BINARY)
26
+ @mode = nil
27
+ @event_name = nil
28
+ @data_lines = []
29
+ @scan_pos = 0
30
+ @depth = 0
31
+ @in_string = false
32
+ @object_start = nil
33
+ end
17
34
 
18
- parse_event_stream(body)
35
+ def <<(chunk)
36
+ @pending << chunk.to_s.b
37
+ @mode ||= detect_mode
38
+ case @mode
39
+ when :sse then consume_lines
40
+ when :array then consume_array
41
+ end
19
42
  end
20
43
 
21
- private
44
+ def pending_bytesize
45
+ @pending.bytesize
46
+ end
22
47
 
23
- def parse_event_stream(body)
24
- events = []
25
- current_event = nil
26
- data_lines = []
48
+ def finish
49
+ return unless @mode == :sse
27
50
 
28
- body.each_line do |raw|
29
- line = raw.chomp
51
+ consume_line(@pending) unless @pending.empty?
52
+ @pending = String.new(encoding: Encoding::BINARY)
53
+ dispatch
54
+ end
30
55
 
31
- if line.empty?
32
- events << finalize_event(current_event, data_lines) if data_lines.any?
33
- current_event = nil
34
- data_lines = []
35
- next
36
- end
56
+ private
37
57
 
38
- next if line.start_with?(":")
58
+ def detect_mode
59
+ first = @pending[/\S/]
60
+ return nil unless first
39
61
 
40
- field, _, value = line.partition(":")
41
- value = value[1..] if value.start_with?(" ")
62
+ first == "[" ? :array : :sse
63
+ end
42
64
 
43
- case field
44
- when "event" then current_event = value
45
- when "data" then data_lines << value
46
- end
65
+ def consume_lines
66
+ start = 0
67
+ while (newline = @pending.index("\n", start))
68
+ consume_line(@pending.byteslice(start, newline - start))
69
+ start = newline + 1
47
70
  end
48
-
49
- events << finalize_event(current_event, data_lines) if data_lines.any?
50
- events.compact
71
+ @pending = @pending.byteslice(start..)
51
72
  end
52
73
 
53
- def parse_json_array(body)
54
- parsed = JSON.parse(body)
55
- return [] unless parsed.is_a?(Array)
74
+ def consume_line(raw)
75
+ line = raw.chomp("\r").force_encoding(Encoding::UTF_8)
76
+ return dispatch if line.empty?
77
+ return if line.start_with?(":")
56
78
 
57
- parsed.map { |entry| { event: nil, data: entry } }
58
- rescue JSON::ParserError
59
- []
79
+ field, _, value = line.partition(":")
80
+ value = value[1..] if value.start_with?(" ")
81
+ case field
82
+ when "event" then @event_name = value
83
+ when "data" then @data_lines << value
84
+ end
85
+ end
86
+
87
+ def dispatch
88
+ emit(finalize_event(@event_name, @data_lines)) if @data_lines.any?
89
+ @event_name = nil
90
+ @data_lines = []
60
91
  end
61
92
 
62
93
  def finalize_event(event_name, data_lines)
@@ -73,6 +104,51 @@ module LlmCostTracker
73
104
  rescue JSON::ParserError
74
105
  payload
75
106
  end
107
+
108
+ def consume_array
109
+ pos = @scan_pos
110
+ while (index = @pending.index(@in_string ? /["\\]/ : /[{}"]/, pos))
111
+ pos = index + 1
112
+ pos = advance_array(@pending.getbyte(index).chr, index, pos)
113
+ end
114
+ trim_array_buffer(pos)
115
+ end
116
+
117
+ def advance_array(char, index, pos)
118
+ if @in_string
119
+ return pos + 1 if char == "\\"
120
+
121
+ @in_string = false
122
+ elsif char == '"'
123
+ @in_string = true
124
+ elsif char == "{"
125
+ @object_start = index if @depth.zero?
126
+ @depth += 1
127
+ elsif @depth.positive?
128
+ @depth -= 1
129
+ emit_object(index) if @depth.zero?
130
+ end
131
+ pos
132
+ end
133
+
134
+ def emit_object(end_index)
135
+ text = @pending.byteslice(@object_start, end_index - @object_start + 1)
136
+ @object_start = nil
137
+ emit({ event: nil, data: JSON.parse(text.force_encoding(Encoding::UTF_8)) })
138
+ rescue JSON::ParserError
139
+ nil
140
+ end
141
+
142
+ def trim_array_buffer(pos)
143
+ keep_from = @object_start || [pos, @pending.bytesize].min
144
+ @pending = @pending.byteslice(keep_from..)
145
+ @scan_pos = pos - keep_from
146
+ @object_start &&= 0
147
+ end
148
+
149
+ def emit(event)
150
+ @on_event.call(event) if event
151
+ end
76
152
  end
77
153
  end
78
154
  end
@@ -4,7 +4,7 @@ require "active_support/core_ext/object/blank"
4
4
  require "active_support/core_ext/object/deep_dup"
5
5
  require "json"
6
6
 
7
- require_relative "sse"
7
+ require_relative "event_window"
8
8
  require_relative "../timing"
9
9
 
10
10
  module LlmCostTracker
@@ -34,9 +34,7 @@ module LlmCostTracker
34
34
  @metadata = (metadata || {}).deep_dup
35
35
  @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
36
36
  @request = request
37
- @events = []
38
- @captured_bytes = 0
39
- @overflowed = false
37
+ @window = EventWindow.new(notable: Parsers.find_for_provider(@provider)&.method(:retain_stream_event?))
40
38
  @explicit_usage = nil
41
39
  @started_at = LlmCostTracker::Timing.now_monotonic
42
40
  @finished = false
@@ -61,7 +59,7 @@ module LlmCostTracker
61
59
  def event(data, type: nil)
62
60
  @mutex.synchronize do
63
61
  ensure_open!
64
- capture_event(data, type: type) unless data.nil?
62
+ @window.push(data, type: type) unless data.nil?
65
63
  end
66
64
  end
67
65
 
@@ -91,6 +89,12 @@ module LlmCostTracker
91
89
  return if snapshot.nil?
92
90
 
93
91
  record_snapshot(snapshot, errored: errored)
92
+ rescue TransactionAbortedError
93
+ raise
94
+ rescue StandardError => e
95
+ raise unless errored
96
+
97
+ Logging.warn("Recording an errored stream raised #{e.class}: #{e.message}; kept the stream's own exception")
94
98
  end
95
99
 
96
100
  private
@@ -102,8 +106,8 @@ module LlmCostTracker
102
106
  @recording = true
103
107
  pricing_mode = Pricing::Mode.normalize(@pricing_mode)
104
108
  {
105
- events: @events.dup,
106
- overflowed: @overflowed,
109
+ events: @window.events,
110
+ overflowed: @window.overflowed?,
107
111
  explicit_usage: @explicit_usage,
108
112
  model: @model,
109
113
  latency_ms: @latency_ms,
@@ -136,10 +140,16 @@ module LlmCostTracker
136
140
  @mutex.synchronize do
137
141
  @finished = save_succeeded
138
142
  @recording = false
143
+ release_buffers if save_succeeded
139
144
  end
140
145
  end
141
146
  end
142
147
 
148
+ def release_buffers
149
+ @window = EventWindow.new
150
+ @request = nil
151
+ end
152
+
143
153
  def capture_dimensions
144
154
  {
145
155
  provider_project_id: @provider_project_id.to_s.strip.presence,
@@ -207,55 +217,6 @@ module LlmCostTracker
207
217
  **snapshot.fetch(:capture_dimensions)
208
218
  )
209
219
  end
210
-
211
- IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
212
- private_constant :IGNORED_PAYLOAD_KEYS
213
-
214
- HEAVY_STRING_BYTES = 8 * 1024
215
- private_constant :HEAVY_STRING_BYTES
216
-
217
- def capture_event(data, type:)
218
- event = { event: type, data: strip_heavy_payload(data) }
219
- size = approximate_bytesize(event)
220
- if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
221
- @events << event
222
- @captured_bytes += size
223
- else
224
- @overflowed = true
225
- end
226
- rescue TypeError, SystemStackError
227
- @overflowed = true
228
- end
229
-
230
- def strip_heavy_payload(value)
231
- case value
232
- when Hash
233
- value.each_with_object({}) do |(key, nested), out|
234
- next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
235
-
236
- out[key] = strip_heavy_payload(nested)
237
- end
238
- when Array
239
- value.map { |nested| strip_heavy_payload(nested) }
240
- when String
241
- value.bytesize > HEAVY_STRING_BYTES ? "" : value
242
- else
243
- value
244
- end
245
- end
246
-
247
- def approximate_bytesize(value)
248
- case value
249
- when Hash
250
- value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
251
- when Array
252
- value.sum { |nested| approximate_bytesize(nested) + 2 }
253
- when Numeric, true, false, nil
254
- 8
255
- else
256
- value.to_s.bytesize + 2
257
- end
258
- end
259
220
  end
260
221
  end
261
222
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event_window"
4
+ require_relative "sse"
5
+
6
+ module LlmCostTracker
7
+ module Capture
8
+ class StreamTap
9
+ MAX_PENDING_BYTES = 16 * 1_048_576
10
+
11
+ def initialize(notable: nil)
12
+ @window = EventWindow.new(notable: notable)
13
+ @reader = SSE::Reader.new { |event| @window.push(event[:data], type: event[:event]) }
14
+ @received = false
15
+ @failed = false
16
+ end
17
+
18
+ def <<(chunk)
19
+ return if @failed
20
+
21
+ @received = true
22
+ @reader << chunk
23
+ fail! if @reader.pending_bytesize > MAX_PENDING_BYTES
24
+ rescue StandardError
25
+ fail!
26
+ end
27
+
28
+ def received?
29
+ @received
30
+ end
31
+
32
+ def failed?
33
+ @failed
34
+ end
35
+
36
+ def overflowed?
37
+ @window.overflowed?
38
+ end
39
+
40
+ def events
41
+ @reader&.finish
42
+ @failed ? [] : @window.events
43
+ rescue StandardError
44
+ fail!
45
+ []
46
+ end
47
+
48
+ private
49
+
50
+ def fail!
51
+ @failed = true
52
+ @reader = nil
53
+ @window = EventWindow.new
54
+ end
55
+ end
56
+ end
57
+ end
@@ -13,8 +13,7 @@ module LlmCostTracker
13
13
  @collector = collector
14
14
  @active = active
15
15
  @finish = finish || proc { |errored| collector.finish!(errored: errored) }
16
- @finished_ref = [false]
17
- @attempted_ref = [false]
16
+ @finished = false
18
17
  @capture_failed = false
19
18
  @mutex = Mutex.new
20
19
  end
@@ -45,7 +44,6 @@ module LlmCostTracker
45
44
  )
46
45
  end
47
46
 
48
- register_orphan_finalizer
49
47
  @stream
50
48
  rescue StandardError => e
51
49
  Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
@@ -77,7 +75,7 @@ module LlmCostTracker
77
75
  yield event
78
76
  end
79
77
  end
80
- rescue StandardError
78
+ rescue Exception # rubocop:disable Lint/RescueException -- mark Interrupt/shutdown too, so it is not masked
81
79
  errored = true
82
80
  raise
83
81
  ensure
@@ -107,10 +105,9 @@ module LlmCostTracker
107
105
 
108
106
  def finish!(errored:)
109
107
  should_finish = @mutex.synchronize do
110
- @attempted_ref[0] = true
111
- next false if @finished_ref[0]
108
+ next false if @finished
112
109
 
113
- @finished_ref[0] = true
110
+ @finished = true
114
111
  true
115
112
  end
116
113
  return unless should_finish && @active.call
@@ -118,37 +115,10 @@ module LlmCostTracker
118
115
  begin
119
116
  @finish.call(errored)
120
117
  rescue StandardError
121
- @mutex.synchronize { @finished_ref[0] = false }
118
+ @mutex.synchronize { @finished = false }
122
119
  raise
123
120
  end
124
121
  end
125
-
126
- def register_orphan_finalizer
127
- finished_ref = @finished_ref
128
- attempted_ref = @attempted_ref
129
- finish_proc = @finish
130
- active_proc = @active
131
- mutex = @mutex
132
- finalizer = lambda do |_object_id|
133
- should_finish = mutex.synchronize do
134
- next false if finished_ref[0] || attempted_ref[0]
135
-
136
- finished_ref[0] = true
137
- attempted_ref[0] = true
138
- true
139
- end
140
- next unless should_finish && active_proc.call
141
-
142
- begin
143
- Rails.application.executor.wrap { finish_proc.call(false) }
144
- rescue StandardError
145
- nil
146
- end
147
- end
148
- ObjectSpace.define_finalizer(@stream, finalizer)
149
- rescue TypeError, ArgumentError
150
- nil
151
- end
152
122
  end
153
123
  end
154
124
  end
@@ -13,6 +13,7 @@ module LlmCostTracker
13
13
  def call
14
14
  path = LlmCostTracker.configuration.pricing.file
15
15
  return bundled_check unless path
16
+ return Check.new(:error, "prices", "#{path} does not exist; #{REFRESH_COMMAND}") unless File.exist?(path)
16
17
 
17
18
  count = LlmCostTracker::Pricing::Registry.file_prices(path).size
18
19
  metadata = LlmCostTracker::Pricing::Registry.file_metadata(path)
@@ -1,10 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "redaction"
4
+
3
5
  module LlmCostTracker
4
6
  class Error < StandardError; end
5
7
 
6
8
  class InvalidFilterError < Error; end
7
9
 
10
+ class TransactionAbortedError < Error
11
+ def initialize(error)
12
+ super(
13
+ "The database rolled back the whole surrounding transaction while recording LLM usage " \
14
+ "(#{error.class}: #{Redaction.text(error.message)}); the caller's transaction no longer exists"
15
+ )
16
+ end
17
+ end
18
+
8
19
  class BudgetExceededError < Error
9
20
  attr_reader :total, :budget, :budget_type, :last_event, :stage, :scope
10
21
 
@@ -40,4 +51,6 @@ module LlmCostTracker
40
51
  super("No pricing configured for LLM model: #{model.inspect}")
41
52
  end
42
53
  end
54
+
55
+ CALLER_ERRORS = [BudgetExceededError, UnknownPricingError, TransactionAbortedError].freeze
43
56
  end
@@ -34,8 +34,8 @@ LlmCostTracker.configure do |config|
34
34
  # "my-custom-model" => { input: 1.00, output: 2.00 }
35
35
  # }
36
36
  # :warn (default) records token usage with nil cost when a model has no rate.
37
- # Use :raise to reject models that have no rate at all; a model priced with one
38
- # component rate missing lands as partial and does not trigger it.
37
+ # Use :raise to record and then raise for models that have no rate at all; a model
38
+ # priced with one component rate missing lands as partial and does not trigger it.
39
39
  config.pricing.unknown_model_behavior = :warn
40
40
 
41
41
  # Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
@@ -63,9 +63,9 @@ LlmCostTracker.configure do |config|
63
63
  # }
64
64
 
65
65
  # Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
66
- # every check; :cache keeps running totals in llm_cost_tracker_call_rollups so checks
67
- # read one row instead — switch once the live SUM gets slow, and generate the table
68
- # first with `bin/rails generate llm_cost_tracker:call_rollups`.
66
+ # every check; :cache also keeps running totals in llm_cost_tracker_call_rollups and
67
+ # takes the greater of the two, so it guards against drift but does not make checks
68
+ # faster. Generate the table first with `bin/rails generate llm_cost_tracker:call_rollups`.
69
69
  # config.budgets.totals_source = :cache
70
70
 
71
71
  # Ingestion path — :inline (default) writes events synchronously from the request
@@ -85,7 +85,7 @@ LlmCostTracker.configure do |config|
85
85
 
86
86
  # Streaming calls only report token usage when the request asks for it. Leave this
87
87
  # on and the Faraday middleware adds stream_options: { include_usage: true } to
88
- # OpenAI-compatible streaming request bodies that don't already set it.
88
+ # streaming chat-completions requests on hosts known to accept it.
89
89
  # config.capture.request_stream_usage = false
90
90
 
91
91
  end
@@ -13,7 +13,8 @@ module LlmCostTracker
13
13
  ActiveRecord::Deadlocked,
14
14
  ActiveRecord::LockWaitTimeout,
15
15
  ActiveRecord::StatementTimeout,
16
- ActiveRecord::ConnectionNotEstablished
16
+ ActiveRecord::ConnectionNotEstablished,
17
+ LlmCostTracker::TransactionAbortedError
17
18
  ].freeze
18
19
 
19
20
  def initialize(identity:)
@@ -25,7 +26,7 @@ module LlmCostTracker
25
26
  return 0 if rows.empty?
26
27
 
27
28
  valid_rows, events = decode(rows)
28
- persist(valid_rows, events) if events.any?
29
+ persist_batch(valid_rows, events) if events.any?
29
30
  rows.size
30
31
  rescue StandardError => e
31
32
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
@@ -67,7 +68,7 @@ module LlmCostTracker
67
68
  end
68
69
 
69
70
  def error_message_for(error)
70
- "#{error.class}: #{error.message}".byteslice(0, 1_000)
71
+ "#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
71
72
  end
72
73
 
73
74
  def warn_on_quarantine(rows)
@@ -75,12 +76,10 @@ module LlmCostTracker
75
76
  quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
76
77
  return if quarantined.empty?
77
78
 
78
- sample = quarantined.first(10).map(&:id).join(", ")
79
- sample += "..." if quarantined.size > 10
80
79
  LlmCostTracker::Logging.warn(
81
80
  "Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
82
81
  "MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
83
- "on the next claim cycle (ids: #{sample})"
82
+ "on the next claim cycle (ids: #{id_sample(quarantined)})"
84
83
  )
85
84
  end
86
85
 
@@ -88,6 +87,11 @@ module LlmCostTracker
88
87
 
89
88
  attr_reader :identity
90
89
 
90
+ def id_sample(rows)
91
+ sample = rows.first(10).map(&:id).join(", ")
92
+ rows.size > 10 ? "#{sample}..." : sample
93
+ end
94
+
91
95
  def claim
92
96
  now = Time.now.utc
93
97
  cutoff = now - LOCK_TIMEOUT_SECONDS
@@ -117,13 +121,38 @@ module LlmCostTracker
117
121
  [valid_rows, events]
118
122
  end
119
123
 
124
+ def persist_batch(rows, events)
125
+ landed = []
126
+ failed = Hash.new { |hash, message| hash[message] = [] }
127
+ landed.concat(persist(rows, events))
128
+ rescue *TRANSIENT_PERSIST_ERRORS
129
+ raise
130
+ rescue StandardError
131
+ rows.zip(events) do |row, event|
132
+ landed.concat(persist([row], [event]))
133
+ rescue *TRANSIENT_PERSIST_ERRORS
134
+ raise
135
+ rescue StandardError => e
136
+ failed[error_message_for(e)] << row
137
+ end
138
+ ensure
139
+ failed.each do |message, failed_rows|
140
+ LlmCostTracker::Logging.warn(
141
+ "Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
142
+ "(ids: #{id_sample(failed_rows)}): #{message}"
143
+ )
144
+ mark_failed_with_message(failed_rows, message)
145
+ end
146
+ Ledger::Rollups.increment_safely!(landed)
147
+ Budget.notify_persisted_safely!(landed)
148
+ end
149
+
120
150
  def persist(rows, events, retry_on_conflict: true)
121
151
  LlmCostTracker::Call.transaction do
122
152
  Ledger::Store.persist_records(events)
123
153
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
124
154
  end
125
- Ledger::Rollups.increment_safely!(events)
126
- Budget.notify_persisted_safely!(events)
155
+ events
127
156
  rescue ActiveRecord::RecordNotUnique
128
157
  raise unless retry_on_conflict
129
158
 
@@ -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)
@@ -21,8 +21,9 @@ module LlmCostTracker
21
21
  host = Openai.client_host_for(resource)
22
22
  Openai.record_safely do
23
23
  io = client.files.content(batch.output_file_id)
24
- capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
24
+ deferred = capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
25
25
  mark_captured(batch.id)
26
+ raise deferred if deferred
26
27
  end
27
28
  end
28
29
 
@@ -41,6 +42,7 @@ module LlmCostTracker
41
42
  end
42
43
 
43
44
  def capture_jsonl(jsonl, host:)
45
+ deferred = nil
44
46
  jsonl.each_line do |line|
45
47
  line = line.strip
46
48
  next if line.empty?
@@ -52,7 +54,10 @@ module LlmCostTracker
52
54
  next unless response.is_a?(Hash) && response["usage"]
53
55
 
54
56
  record_result(response, host: host)
57
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
58
+ deferred ||= e
55
59
  end
60
+ deferred
56
61
  end
57
62
 
58
63
  def parse_line(line)