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
@@ -37,25 +37,40 @@ module LlmCostTracker
37
37
  config = LlmCostTracker.configuration
38
38
  return unless event.total_cost
39
39
 
40
- check_per_call_budget(event, config, behavior_override)
40
+ errors = [check_per_call_budget(event, config, behavior_override)]
41
41
  check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
42
42
  time: event.tracked_at) do |budget_type, total, budget|
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)
43
+ errors << 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
49
  end
50
+ errors.concat(persisted_errors([event], behavior_override)) unless Ingestion.async?
51
+ raise_first(errors)
50
52
  end
51
53
 
52
54
  def check_persisted!(events, behavior_override: nil)
53
- by_rule = PerTag.rules_for_events(events.select(&:total_cost))
55
+ raise_first(persisted_errors(events, behavior_override))
56
+ end
57
+
58
+ def notify_persisted_safely!(events)
59
+ check_persisted!(events, behavior_override: :notify)
60
+ rescue StandardError => e
61
+ Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
62
+ end
63
+
64
+ private
65
+
66
+ def persisted_errors(events, behavior_override)
67
+ by_rule = PerTag.rules_for_events(events)
54
68
  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
69
+ window_buckets(by_rule).flat_map do |(key, window, bucket), scored|
70
+ upto = scored.values.flatten.map(&:tracked_at).max unless Ingestion.async?
71
+ totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket, upto)
72
+ scored.filter_map do |rule, recorded|
73
+ total, upto_total = totals.fetch(rule.value, [0, 0])
59
74
  limit = rule.windows.fetch(window)
60
75
  next if total < limit
61
76
 
@@ -63,7 +78,7 @@ module LlmCostTracker
63
78
  budget_type: window,
64
79
  total: total,
65
80
  budget: limit,
66
- previous_total: total - recorded.sum(&:total_cost),
81
+ previous_total: (upto_total >= limit ? upto_total : total) - recorded.sum(&:total_cost),
67
82
  last_event: recorded.last,
68
83
  scope: scope_for(rule),
69
84
  behavior: behavior_override || rule.behavior,
@@ -73,14 +88,11 @@ module LlmCostTracker
73
88
  end
74
89
  end
75
90
 
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}")
91
+ def raise_first(errors)
92
+ error = errors.compact.first
93
+ raise error if error
80
94
  end
81
95
 
82
- private
83
-
84
96
  def window_buckets(by_rule)
85
97
  by_rule.each_with_object({}) do |(rule, events), grouped|
86
98
  rule.windows.each_key do |window|
@@ -98,14 +110,6 @@ module LlmCostTracker
98
110
  Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
99
111
  end
100
112
 
101
- def raise_per_call_pre_send(estimate, budget)
102
- return unless estimate >= budget
103
-
104
- raise BudgetExceededError.new(**budget_payload(
105
- budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
106
- ))
107
- end
108
-
109
113
  def check_per_call_budget(event, config, behavior_override)
110
114
  budget = config.budgets.per_call
111
115
  return unless budget
@@ -122,7 +126,10 @@ module LlmCostTracker
122
126
  end
123
127
 
124
128
  def enforce_globally(config, estimate:, time:)
125
- raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
129
+ per_call = config.budgets.per_call
130
+ if per_call && estimate.positive? && estimate >= per_call
131
+ raise_pre_send(budget_type: :per_call, total: estimate, budget: per_call)
132
+ end
126
133
 
127
134
  check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
128
135
  time: time,
@@ -132,9 +139,9 @@ module LlmCostTracker
132
139
  end
133
140
 
134
141
  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
- ))
142
+ raise BudgetExceededError.new(
143
+ budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
144
+ )
138
145
  end
139
146
 
140
147
  def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
@@ -153,21 +160,14 @@ module LlmCostTracker
153
160
  def check_windowed(budgets, time:, estimate: BigDecimal("0"))
154
161
  return if budgets.empty?
155
162
 
156
- totals = totals_for(budgets.keys, time: time)
163
+ periods = budgets.keys.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
164
+ totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
157
165
  budgets.each do |budget_type, budget|
158
- total = totals.fetch(budget_type) + estimate
166
+ total = totals.fetch(BUDGET_TYPE_TO_PERIOD.fetch(budget_type)) + estimate
159
167
  yield(budget_type, total, budget) if total >= budget
160
168
  end
161
169
  end
162
170
 
163
- def totals_for(budget_types, time:)
164
- return {} if budget_types.empty?
165
-
166
- period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
167
- period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
168
- period_for.transform_values { |period| period_totals.fetch(period) }
169
- end
170
-
171
171
  def handle_exceeded(budget_type:,
172
172
  total:,
173
173
  budget:,
@@ -179,28 +179,17 @@ module LlmCostTracker
179
179
  config = LlmCostTracker.configuration
180
180
  behavior ||= config.budgets.exceeded_behavior
181
181
  on_exceeded ||= config.budgets.on_exceeded
182
- payload = budget_payload(
182
+ payload = {
183
183
  budget_type: budget_type,
184
184
  total: total,
185
185
  budget: budget,
186
186
  last_event: last_event,
187
187
  stage: :post_spend,
188
188
  scope: scope
189
- )
189
+ }
190
190
 
191
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)
193
- end
194
-
195
- def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
196
- {
197
- budget_type: budget_type,
198
- total: total,
199
- budget: budget,
200
- last_event: last_event,
201
- stage: stage,
202
- scope: scope
203
- }
192
+ BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
204
193
  end
205
194
  end
206
195
  end
@@ -0,0 +1,100 @@
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 snapshot logprobs].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
+ # The openai gem's chat stream helper resends all logprobs so far in logprobs.* events; none carry usage.
24
+ return if @overflowed || type&.start_with?("logprobs.")
25
+
26
+ event = { event: type, data: strip_heavy_payload(data) }
27
+ size = approximate_bytesize(event)
28
+ if @head.size < HEAD_EVENTS
29
+ @head << [event, size]
30
+ else
31
+ @tail << [event, size]
32
+ settle(@tail.shift) if @tail.size > TAIL_EVENTS
33
+ end
34
+ @bytes += size
35
+ overflow! if @bytes > SSE::LIMIT_BYTES
36
+ rescue TypeError, SystemStackError
37
+ overflow!
38
+ end
39
+
40
+ def events
41
+ (@head + @kept + @tail).map(&:first)
42
+ end
43
+
44
+ def overflowed?
45
+ @overflowed
46
+ end
47
+
48
+ private
49
+
50
+ def settle(entry)
51
+ return @kept << entry if notable?(entry.first[:data])
52
+
53
+ @bytes -= entry.last
54
+ end
55
+
56
+ def notable?(data)
57
+ @notable&.call(data)
58
+ rescue StandardError
59
+ false
60
+ end
61
+
62
+ def overflow!
63
+ @overflowed = true
64
+ @head = []
65
+ @kept = []
66
+ @tail = []
67
+ end
68
+
69
+ def strip_heavy_payload(value)
70
+ case value
71
+ when Hash
72
+ value.each_with_object({}) do |(key, nested), out|
73
+ next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
74
+
75
+ out[key] = strip_heavy_payload(nested)
76
+ end
77
+ when Array
78
+ value.map { |nested| strip_heavy_payload(nested) }
79
+ when String
80
+ value.bytesize > HEAVY_STRING_BYTES ? "" : value
81
+ else
82
+ value
83
+ end
84
+ end
85
+
86
+ def approximate_bytesize(value)
87
+ case value
88
+ when Hash
89
+ value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
90
+ when Array
91
+ value.sum { |nested| approximate_bytesize(nested) + 2 }
92
+ when Numeric, true, false, nil
93
+ 8
94
+ else
95
+ value.to_s.bytesize + 2
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -3,6 +3,8 @@
3
3
  require "active_support/core_ext/object/deep_dup"
4
4
  require "active_support/core_ext/object/try"
5
5
 
6
+ require_relative "event_window"
7
+
6
8
  module LlmCostTracker
7
9
  module Capture
8
10
  module SdkPayload
@@ -11,7 +13,9 @@ module LlmCostTracker
11
13
  def normalize(value)
12
14
  case value
13
15
  when Hash
14
- value.each_with_object({}) { |(key, nested), out| out[key.to_s] = normalize(nested) }
16
+ value.each_with_object({}) do |(key, nested), out|
17
+ out[key.to_s] = normalize(nested) unless EventWindow::IGNORED_PAYLOAD_KEYS.include?(key.to_s)
18
+ end
15
19
  when Array
16
20
  value.map { |nested| normalize(nested) }
17
21
  when Symbol
@@ -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,8 @@ 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
+ require_relative "sdk_payload"
8
9
  require_relative "../timing"
9
10
 
10
11
  module LlmCostTracker
@@ -21,7 +22,6 @@ module LlmCostTracker
21
22
  provider_workspace_id: nil,
22
23
  pricing_mode: nil,
23
24
  metadata: {},
24
- context_tags: nil,
25
25
  request: nil)
26
26
  @provider = provider.to_s
27
27
  @model = model
@@ -32,11 +32,9 @@ module LlmCostTracker
32
32
  @provider_workspace_id = provider_workspace_id
33
33
  @pricing_mode = pricing_mode
34
34
  @metadata = (metadata || {}).deep_dup
35
- @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
35
+ @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
@@ -59,9 +57,10 @@ module LlmCostTracker
59
57
  end
60
58
 
61
59
  def event(data, type: nil)
60
+ data = SdkPayload.normalize(data) if data.is_a?(Hash)
62
61
  @mutex.synchronize do
63
62
  ensure_open!
64
- capture_event(data, type: type) unless data.nil?
63
+ @window.push(data, type: type&.to_s) unless data.nil?
65
64
  end
66
65
  end
67
66
 
@@ -91,6 +90,14 @@ module LlmCostTracker
91
90
  return if snapshot.nil?
92
91
 
93
92
  record_snapshot(snapshot, errored: errored)
93
+ rescue TransactionAbortedError
94
+ raise
95
+ rescue ActiveRecord::RecordNotUnique
96
+ nil
97
+ rescue StandardError => e
98
+ raise unless errored
99
+
100
+ Logging.warn("Recording an errored stream raised #{e.class}: #{e.message}; kept the stream's own exception")
94
101
  end
95
102
 
96
103
  private
@@ -102,8 +109,8 @@ module LlmCostTracker
102
109
  @recording = true
103
110
  pricing_mode = Pricing::Mode.normalize(@pricing_mode)
104
111
  {
105
- events: @events.dup,
106
- overflowed: @overflowed,
112
+ events: @window.events,
113
+ overflowed: @window.overflowed?,
107
114
  explicit_usage: @explicit_usage,
108
115
  model: @model,
109
116
  latency_ms: @latency_ms,
@@ -136,10 +143,16 @@ module LlmCostTracker
136
143
  @mutex.synchronize do
137
144
  @finished = save_succeeded
138
145
  @recording = false
146
+ release_buffers if save_succeeded
139
147
  end
140
148
  end
141
149
  end
142
150
 
151
+ def release_buffers
152
+ @window = EventWindow.new
153
+ @request = nil
154
+ end
155
+
143
156
  def capture_dimensions
144
157
  {
145
158
  provider_project_id: @provider_project_id.to_s.strip.presence,
@@ -155,20 +168,27 @@ module LlmCostTracker
155
168
  end
156
169
 
157
170
  def build_event(snapshot)
158
- return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
159
- return build_unknown_usage(snapshot) if snapshot[:overflowed]
171
+ return build_unparsed_event(snapshot) if snapshot[:explicit_usage]
160
172
 
161
- event = Parsers.find_for_provider(@provider)&.parse_stream(
162
- response_status: 200,
163
- events: snapshot[:events],
164
- request_body: request_body_for(snapshot[:request])
165
- )
173
+ if snapshot[:overflowed]
174
+ Logging.warn("#{@provider} stream events exceeded #{SSE::LIMIT_BYTES} bytes; " \
175
+ "recording usage_source=#{Usage::Source::UNKNOWN}.")
176
+ return build_unparsed_event(snapshot)
177
+ end
178
+
179
+ request_body = request_body_for(snapshot[:request])
180
+ events = Parsers.all_for_provider(@provider).filter_map do |parser|
181
+ parser.parse_stream(
182
+ response_status: 200, events: snapshot[:events], request_body: request_body, model: snapshot[:model]
183
+ )
184
+ end
185
+ event = events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
166
186
  if event
167
187
  model = present_model(event.model) || present_model(snapshot[:model]) || Event::UNKNOWN_MODEL
168
188
  return event.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
169
189
  end
170
190
 
171
- build_unknown_usage(snapshot)
191
+ build_unparsed_event(snapshot)
172
192
  end
173
193
 
174
194
  def request_body_for(request)
@@ -184,78 +204,18 @@ module LlmCostTracker
184
204
  string unless string == Event::UNKNOWN_MODEL
185
205
  end
186
206
 
187
- def build_from_explicit_usage(snapshot)
207
+ def build_unparsed_event(snapshot)
208
+ explicit_usage = snapshot[:explicit_usage]
188
209
  Event.build(
189
210
  provider: @provider,
190
211
  model: snapshot[:model] || Event::UNKNOWN_MODEL,
191
- token_usage: snapshot[:explicit_usage],
212
+ token_usage: explicit_usage || Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
192
213
  stream: true,
193
- usage_source: Usage::Source::MANUAL,
214
+ usage_source: explicit_usage ? Usage::Source::MANUAL : Usage::Source::UNKNOWN,
194
215
  pricing_mode: snapshot[:pricing_mode],
195
216
  **snapshot.fetch(:capture_dimensions)
196
217
  )
197
218
  end
198
-
199
- def build_unknown_usage(snapshot)
200
- Event.build(
201
- provider: @provider,
202
- model: snapshot[:model] || Event::UNKNOWN_MODEL,
203
- token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
204
- stream: true,
205
- usage_source: Usage::Source::UNKNOWN,
206
- pricing_mode: snapshot[:pricing_mode],
207
- **snapshot.fetch(:capture_dimensions)
208
- )
209
- 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
219
  end
260
220
  end
261
221
  end