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
@@ -4,42 +4,94 @@ require "bigdecimal"
4
4
 
5
5
  require_relative "ledger"
6
6
  require_relative "pricing/estimator"
7
+ require_relative "budget/per_tag"
8
+ require_relative "tags/context"
7
9
 
8
10
  module LlmCostTracker
9
11
  module Budget
10
12
  BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
11
13
 
12
14
  class << self
13
- def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
15
+ def enforce!(provider: nil, model: nil, request: nil, estimate: nil, tags: nil, force: false)
14
16
  config = LlmCostTracker.configuration
15
17
  return unless config.enabled
16
- return unless force || config.budget_exceeded_behavior == :block_requests
18
+
19
+ globally = force || config.budgets.exceeded_behavior == :block_requests
20
+ per_tag = force || PerTag.blocking?
21
+ return unless globally || per_tag
17
22
 
18
23
  estimate ||= estimate_cost(provider: provider, model: model, request: request)
19
- raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
20
-
21
- check_windowed({ monthly: config.monthly_budget, daily: config.daily_budget }.compact,
22
- time: Time.now.utc,
23
- estimate: estimate) do |budget_type, total, budget|
24
- raise BudgetExceededError.new(**budget_payload(
25
- budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
26
- ))
24
+ now = Time.now.utc
25
+ enforce_globally(config, estimate: estimate, time: now) if globally
26
+ return unless per_tag
27
+
28
+ check_per_tag(tags || Tags::Context.tags,
29
+ time: now,
30
+ estimate: estimate,
31
+ blocking_only: !force) do |rule, window, total, limit|
32
+ raise_pre_send(budget_type: window, total: total, budget: limit, scope: scope_for(rule))
27
33
  end
28
34
  end
29
35
 
30
- def check!(event)
36
+ def check!(event, behavior_override: nil)
31
37
  config = LlmCostTracker.configuration
32
38
  return unless event.total_cost
33
39
 
34
- check_per_call_budget(event, config)
35
- check_windowed({ daily: config.daily_budget, monthly: config.monthly_budget }.compact,
40
+ check_per_call_budget(event, config, behavior_override)
41
+ check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
36
42
  time: event.tracked_at) do |budget_type, total, budget|
37
- handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event)
43
+ handle_exceeded(budget_type: budget_type,
44
+ total: total,
45
+ budget: budget,
46
+ previous_total: total - event.total_cost,
47
+ last_event: event,
48
+ behavior: behavior_override)
49
+ end
50
+ end
51
+
52
+ def check_persisted!(events, behavior_override: nil)
53
+ by_rule = PerTag.rules_for_events(events.select(&:total_cost))
54
+ by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
55
+ window_buckets(by_rule).each do |(key, window, bucket), scored|
56
+ totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket)
57
+ scored.each do |rule, recorded|
58
+ total = totals.fetch(rule.value, 0).to_d
59
+ limit = rule.windows.fetch(window)
60
+ next if total < limit
61
+
62
+ handle_exceeded(
63
+ budget_type: window,
64
+ total: total,
65
+ budget: limit,
66
+ previous_total: total - recorded.sum(&:total_cost),
67
+ last_event: recorded.last,
68
+ scope: scope_for(rule),
69
+ behavior: behavior_override || rule.behavior,
70
+ on_exceeded: rule.on_exceeded
71
+ )
72
+ end
38
73
  end
39
74
  end
40
75
 
76
+ def notify_persisted_safely!(events)
77
+ check_persisted!(events, behavior_override: :notify)
78
+ rescue StandardError => e
79
+ Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
80
+ end
81
+
41
82
  private
42
83
 
84
+ def window_buckets(by_rule)
85
+ by_rule.each_with_object({}) do |(rule, events), grouped|
86
+ rule.windows.each_key do |window|
87
+ events.group_by { |event| PerTag.window_start(window, event.tracked_at) }
88
+ .each do |bucket, bucket_events|
89
+ (grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events
90
+ end
91
+ end
92
+ end
93
+ end
94
+
43
95
  def estimate_cost(provider:, model:, request:)
44
96
  return BigDecimal("0") unless provider && model && request
45
97
 
@@ -54,14 +106,48 @@ estimate: estimate) do |budget_type, total, budget|
54
106
  ))
55
107
  end
56
108
 
57
- def check_per_call_budget(event, config)
58
- budget = config.per_call_budget
109
+ def check_per_call_budget(event, config, behavior_override)
110
+ budget = config.budgets.per_call
59
111
  return unless budget
60
112
 
61
113
  total = event.total_cost
62
114
  return unless total >= budget
63
115
 
64
- handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
116
+ handle_exceeded(budget_type: :per_call,
117
+ total: total,
118
+ budget: budget,
119
+ previous_total: nil,
120
+ last_event: event,
121
+ behavior: behavior_override)
122
+ end
123
+
124
+ def enforce_globally(config, estimate:, time:)
125
+ raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
126
+
127
+ check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
128
+ time: time,
129
+ estimate: estimate) do |budget_type, total, budget|
130
+ raise_pre_send(budget_type: budget_type, total: total, budget: budget)
131
+ end
132
+ end
133
+
134
+ def raise_pre_send(budget_type:, total:, budget:, scope: nil)
135
+ raise BudgetExceededError.new(**budget_payload(
136
+ budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send, scope: scope
137
+ ))
138
+ end
139
+
140
+ def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
141
+ PerTag.rules_for(tags, blocking_only: blocking_only).each do |rule|
142
+ rule.windows.each do |window, limit|
143
+ total = PerTag.spend(rule.key, rule.value, window, time: time) + estimate
144
+ yield(rule, window, total, limit) if total >= limit
145
+ end
146
+ end
147
+ end
148
+
149
+ def scope_for(rule)
150
+ { key: rule.key, value: rule.value }
65
151
  end
66
152
 
67
153
  def check_windowed(budgets, time:, estimate: BigDecimal("0"))
@@ -82,38 +168,40 @@ estimate: estimate) do |budget_type, total, budget|
82
168
  period_for.transform_values { |period| period_totals.fetch(period) }
83
169
  end
84
170
 
85
- def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
171
+ def handle_exceeded(budget_type:,
172
+ total:,
173
+ budget:,
174
+ previous_total:,
175
+ last_event: nil,
176
+ scope: nil,
177
+ behavior: nil,
178
+ on_exceeded: nil)
86
179
  config = LlmCostTracker.configuration
180
+ behavior ||= config.budgets.exceeded_behavior
181
+ on_exceeded ||= config.budgets.on_exceeded
87
182
  payload = budget_payload(
88
183
  budget_type: budget_type,
89
184
  total: total,
90
185
  budget: budget,
91
186
  last_event: last_event,
92
- stage: :post_spend
187
+ stage: :post_spend,
188
+ scope: scope
93
189
  )
94
190
 
95
- if notify_exceeded?(config, budget_type: budget_type, total: total, budget: budget, last_event: last_event)
96
- config.on_budget_exceeded&.call(payload)
97
- end
98
- raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
191
+ on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
192
+ raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
99
193
  end
100
194
 
101
- def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
195
+ def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
102
196
  {
103
197
  budget_type: budget_type,
104
198
  total: total,
105
199
  budget: budget,
106
200
  last_event: last_event,
107
- stage: stage
201
+ stage: stage,
202
+ scope: scope
108
203
  }
109
204
  end
110
-
111
- def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
112
- return false unless config.on_budget_exceeded
113
- return true if !last_event&.total_cost || budget_type == :per_call
114
-
115
- total - last_event.total_cost < budget
116
- end
117
205
  end
118
206
  end
119
207
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sse"
4
+
5
+ module LlmCostTracker
6
+ module Capture
7
+ class EventWindow
8
+ HEAD_EVENTS = 16
9
+ TAIL_EVENTS = 32
10
+ HEAVY_STRING_BYTES = 8 * 1024
11
+ IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
12
+
13
+ def initialize(notable: nil)
14
+ @notable = notable
15
+ @head = []
16
+ @kept = []
17
+ @tail = []
18
+ @bytes = 0
19
+ @overflowed = false
20
+ end
21
+
22
+ def push(data, type: nil)
23
+ return if @overflowed
24
+
25
+ event = { event: type, data: strip_heavy_payload(data) }
26
+ size = approximate_bytesize(event)
27
+ if @head.size < HEAD_EVENTS
28
+ @head << [event, size]
29
+ else
30
+ @tail << [event, size]
31
+ settle(@tail.shift) if @tail.size > TAIL_EVENTS
32
+ end
33
+ @bytes += size
34
+ overflow! if @bytes > SSE::LIMIT_BYTES
35
+ rescue TypeError, SystemStackError
36
+ overflow!
37
+ end
38
+
39
+ def events
40
+ (@head + @kept + @tail).map(&:first)
41
+ end
42
+
43
+ def overflowed?
44
+ @overflowed
45
+ end
46
+
47
+ private
48
+
49
+ def settle(entry)
50
+ return @kept << entry if notable?(entry.first[:data])
51
+
52
+ @bytes -= entry.last
53
+ end
54
+
55
+ def notable?(data)
56
+ @notable&.call(data)
57
+ rescue StandardError
58
+ false
59
+ end
60
+
61
+ def overflow!
62
+ @overflowed = true
63
+ @head = []
64
+ @kept = []
65
+ @tail = []
66
+ end
67
+
68
+ def strip_heavy_payload(value)
69
+ case value
70
+ when Hash
71
+ value.each_with_object({}) do |(key, nested), out|
72
+ next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
73
+
74
+ out[key] = strip_heavy_payload(nested)
75
+ end
76
+ when Array
77
+ value.map { |nested| strip_heavy_payload(nested) }
78
+ when String
79
+ value.bytesize > HEAVY_STRING_BYTES ? "" : value
80
+ else
81
+ value
82
+ end
83
+ end
84
+
85
+ def approximate_bytesize(value)
86
+ case value
87
+ when Hash
88
+ value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
89
+ when Array
90
+ value.sum { |nested| approximate_bytesize(nested) + 2 }
91
+ when Numeric, true, false, nil
92
+ 8
93
+ else
94
+ value.to_s.bytesize + 2
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -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