llm_cost_tracker 0.14.2 → 0.15.0

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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -1,18 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bigdecimal"
4
-
5
- require_relative "ledger"
6
- require_relative "pricing/estimator"
7
- require_relative "budget/per_tag"
8
- require_relative "tags/context"
3
+ require_relative "budget/estimate"
4
+ require_relative "budget/global"
5
+ require_relative "budget/tagged"
9
6
 
10
7
  module LlmCostTracker
11
8
  module Budget
12
- BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
13
-
14
9
  class << self
15
- def enforce!(provider: nil, model: nil, request: nil, estimate: nil, tags: nil, force: false)
10
+ def enforce!(provider: nil, model: nil, request: nil, tags: nil, force: false)
16
11
  config = LlmCostTracker.configuration
17
12
  return unless config.enabled
18
13
 
@@ -20,39 +15,20 @@ module LlmCostTracker
20
15
  per_tag = force || PerTag.blocking?
21
16
  return unless globally || per_tag
22
17
 
23
- estimate ||= estimate_cost(provider: provider, model: model, request: request)
18
+ estimate = Estimate.for(provider: provider, model: model, request: request)
24
19
  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))
33
- end
20
+ Global.enforce!(estimate, time: now) if globally
21
+ Tagged.enforce!(tags, estimate.total, time: now, blocking_only: !force) if per_tag
34
22
  end
35
23
 
36
24
  def check!(event, behavior_override: nil)
37
- config = LlmCostTracker.configuration
38
- return unless event.total_cost
39
-
40
- errors = [check_per_call_budget(event, config, behavior_override)]
41
- check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
42
- time: event.tracked_at) do |budget_type, total, budget|
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
- end
50
- errors.concat(persisted_errors([event], behavior_override)) unless Ingestion.async?
25
+ errors = event.total_cost ? Global.post_spend_errors(event, behavior_override) : []
26
+ errors.concat(Tagged.post_spend_errors([event], behavior_override)) unless Ingestion.async?
51
27
  raise_first(errors)
52
28
  end
53
29
 
54
30
  def check_persisted!(events, behavior_override: nil)
55
- raise_first(persisted_errors(events, behavior_override))
31
+ raise_first(Tagged.post_spend_errors(events, behavior_override))
56
32
  end
57
33
 
58
34
  def notify_persisted_safely!(events)
@@ -61,136 +37,22 @@ module LlmCostTracker
61
37
  Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
62
38
  end
63
39
 
64
- private
65
-
66
- def persisted_errors(events, behavior_override)
67
- by_rule = PerTag.rules_for_events(events)
68
- by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
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])
74
- limit = rule.windows.fetch(window)
75
- next if total < limit
40
+ def notify_repriced_safely!(changes)
41
+ return if changes.empty?
76
42
 
77
- handle_exceeded(
78
- budget_type: window,
79
- total: total,
80
- budget: limit,
81
- previous_total: (upto_total >= limit ? upto_total : total) - recorded.sum(&:total_cost),
82
- last_event: recorded.last,
83
- scope: scope_for(rule),
84
- behavior: behavior_override || rule.behavior,
85
- on_exceeded: rule.on_exceeded
86
- )
87
- end
88
- end
43
+ now = Time.now.utc
44
+ Global.notify_repriced(changes, time: now)
45
+ Tagged.notify_repriced(changes, time: now)
46
+ rescue StandardError => e
47
+ Logging.warn("Budget check failed after repricing: #{e.class}: #{e.message}")
89
48
  end
90
49
 
50
+ private
51
+
91
52
  def raise_first(errors)
92
53
  error = errors.compact.first
93
54
  raise error if error
94
55
  end
95
-
96
- def window_buckets(by_rule)
97
- by_rule.each_with_object({}) do |(rule, events), grouped|
98
- rule.windows.each_key do |window|
99
- events.group_by { |event| PerTag.window_start(window, event.tracked_at) }
100
- .each do |bucket, bucket_events|
101
- (grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events
102
- end
103
- end
104
- end
105
- end
106
-
107
- def estimate_cost(provider:, model:, request:)
108
- return BigDecimal("0") unless provider && model && request
109
-
110
- Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
111
- end
112
-
113
- def check_per_call_budget(event, config, behavior_override)
114
- budget = config.budgets.per_call
115
- return unless budget
116
-
117
- total = event.total_cost
118
- return unless total >= budget
119
-
120
- handle_exceeded(budget_type: :per_call,
121
- total: total,
122
- budget: budget,
123
- previous_total: nil,
124
- last_event: event,
125
- behavior: behavior_override)
126
- end
127
-
128
- def enforce_globally(config, estimate:, time:)
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
133
-
134
- check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
135
- time: time,
136
- estimate: estimate) do |budget_type, total, budget|
137
- raise_pre_send(budget_type: budget_type, total: total, budget: budget)
138
- end
139
- end
140
-
141
- def raise_pre_send(budget_type:, total:, budget:, scope: nil)
142
- raise BudgetExceededError.new(
143
- budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
144
- )
145
- end
146
-
147
- def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
148
- PerTag.rules_for(tags, blocking_only: blocking_only).each do |rule|
149
- rule.windows.each do |window, limit|
150
- total = PerTag.spend(rule.key, rule.value, window, time: time) + estimate
151
- yield(rule, window, total, limit) if total >= limit
152
- end
153
- end
154
- end
155
-
156
- def scope_for(rule)
157
- { key: rule.key, value: rule.value }
158
- end
159
-
160
- def check_windowed(budgets, time:, estimate: BigDecimal("0"))
161
- return if budgets.empty?
162
-
163
- periods = budgets.keys.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
164
- totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
165
- budgets.each do |budget_type, budget|
166
- total = totals.fetch(BUDGET_TYPE_TO_PERIOD.fetch(budget_type)) + estimate
167
- yield(budget_type, total, budget) if total >= budget
168
- end
169
- end
170
-
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)
179
- config = LlmCostTracker.configuration
180
- behavior ||= config.budgets.exceeded_behavior
181
- on_exceeded ||= config.budgets.on_exceeded
182
- payload = {
183
- budget_type: budget_type,
184
- total: total,
185
- budget: budget,
186
- last_event: last_event,
187
- stage: :post_spend,
188
- scope: scope
189
- }
190
-
191
- on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
192
- BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
193
- end
194
56
  end
195
57
  end
196
58
  end
@@ -8,10 +8,11 @@ module LlmCostTracker
8
8
  HEAD_EVENTS = 16
9
9
  TAIL_EVENTS = 32
10
10
  HEAVY_STRING_BYTES = 8 * 1024
11
- IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64 snapshot logprobs].freeze
11
+ IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64 snapshot logprobs embedding].freeze
12
12
 
13
- def initialize(notable: nil)
13
+ def initialize(notable: nil, trim: nil)
14
14
  @notable = notable
15
+ @trim = trim
15
16
  @head = []
16
17
  @kept = []
17
18
  @tail = []
@@ -20,19 +21,10 @@ module LlmCostTracker
20
21
  end
21
22
 
22
23
  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
24
  return if @overflowed || type&.start_with?("logprobs.")
25
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
26
+ event = { event: type, data: strip_heavy_payload(@trim ? @trim.call(data) : data) }
27
+ append([event, approximate_bytesize(event)])
36
28
  rescue TypeError, SystemStackError
37
29
  overflow!
38
30
  end
@@ -47,6 +39,17 @@ module LlmCostTracker
47
39
 
48
40
  private
49
41
 
42
+ def append(entry)
43
+ if @head.size < HEAD_EVENTS
44
+ @head << entry
45
+ else
46
+ @tail << entry
47
+ settle(@tail.shift) if @tail.size > TAIL_EVENTS
48
+ end
49
+ @bytes += entry.last
50
+ overflow! if @bytes > SSE::LIMIT_BYTES
51
+ end
52
+
50
53
  def settle(entry)
51
54
  return @kept << entry if notable?(entry.first[:data])
52
55
 
@@ -8,30 +8,35 @@ require_relative "event_window"
8
8
  module LlmCostTracker
9
9
  module Capture
10
10
  module SdkPayload
11
- module_function
11
+ class << self
12
+ def normalize(value)
13
+ case value
14
+ when Hash then normalize_hash(value)
15
+ when Array then value.map { |nested| normalize(nested) }
16
+ when Symbol then value.to_s
17
+ when NilClass then nil
18
+ else normalize_object(value)
19
+ end
20
+ end
21
+
22
+ private
12
23
 
13
- def normalize(value)
14
- case value
15
- when Hash
16
- value.each_with_object({}) do |(key, nested), out|
24
+ def normalize_hash(hash)
25
+ hash.each_with_object({}) do |(key, nested), out|
17
26
  out[key.to_s] = normalize(nested) unless EventWindow::IGNORED_PAYLOAD_KEYS.include?(key.to_s)
18
27
  end
19
- when Array
20
- value.map { |nested| normalize(nested) }
21
- when Symbol
22
- value.to_s
23
- when NilClass
24
- nil
25
- else
28
+ end
29
+
30
+ def normalize_object(value)
26
31
  converted = container_for(value)
27
32
  converted ? normalize(converted) : value.deep_dup
28
33
  end
29
- end
30
34
 
31
- def container_for(value)
32
- value.try(:deep_to_h) || value.try(:to_h)
33
- rescue StandardError
34
- nil
35
+ def container_for(value)
36
+ value.try(:to_h)
37
+ rescue StandardError
38
+ nil
39
+ end
35
40
  end
36
41
  end
37
42
  end
@@ -64,11 +64,12 @@ module LlmCostTracker
64
64
 
65
65
  def consume_lines
66
66
  start = 0
67
- while (newline = @pending.index("\n", start))
67
+ while (newline = @pending.index("\n", [start, @scan_pos].max))
68
68
  consume_line(@pending.byteslice(start, newline - start))
69
69
  start = newline + 1
70
70
  end
71
- @pending = @pending.byteslice(start..)
71
+ @pending.slice!(0, start) if start.positive?
72
+ @scan_pos = @pending.bytesize
72
73
  end
73
74
 
74
75
  def consume_line(raw)
@@ -140,9 +141,10 @@ module LlmCostTracker
140
141
  end
141
142
 
142
143
  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
144
+ size = @pending.bytesize
145
+ keep_from = @object_start || [pos, size].min
146
+ @pending.slice!(0, keep_from) if keep_from.positive?
147
+ @scan_pos = [pos, size].max - keep_from
146
148
  @object_start &&= 0
147
149
  end
148
150
 
@@ -11,10 +11,16 @@ require_relative "../timing"
11
11
  module LlmCostTracker
12
12
  module Capture
13
13
  class StreamCollector
14
+ DIMENSIONS = %i[provider_project_id provider_api_key_id provider_workspace_id].freeze
15
+ Snapshot = Data.define(*%i[events overflowed explicit_usage model latency_ms provider_response_id
16
+ capture_dimensions pricing_mode metadata context_tags request])
17
+ private_constant :DIMENSIONS, :Snapshot
18
+
14
19
  attr_reader :provider
15
20
 
16
21
  def initialize(provider:,
17
22
  model:,
23
+ parsed_as: provider,
18
24
  latency_ms: nil,
19
25
  provider_response_id: nil,
20
26
  provider_project_id: nil,
@@ -24,44 +30,33 @@ module LlmCostTracker
24
30
  metadata: {},
25
31
  request: nil)
26
32
  @provider = provider.to_s
33
+ @parsed_as = parsed_as.to_s
27
34
  @model = model
28
35
  @latency_ms = latency_ms
29
36
  @provider_response_id = provider_response_id
30
- @provider_project_id = provider_project_id
31
- @provider_api_key_id = provider_api_key_id
32
- @provider_workspace_id = provider_workspace_id
37
+ @dimensions = { provider_project_id:, provider_api_key_id:, provider_workspace_id: }
33
38
  @pricing_mode = pricing_mode
34
39
  @metadata = (metadata || {}).deep_dup
35
40
  @context_tags = LlmCostTracker::Tags::Context.tags.deep_dup
36
41
  @request = request
37
- @window = EventWindow.new(notable: Parsers.find_for_provider(@provider)&.method(:retain_stream_event?))
42
+ @window = parser_window
38
43
  @explicit_usage = nil
39
44
  @started_at = LlmCostTracker::Timing.now_monotonic
40
- @finished = false
41
- @recording = false
45
+ @state = :open
42
46
  @mutex = Mutex.new
43
47
  end
44
48
 
45
49
  def model=(value)
46
- @mutex.synchronize do
47
- ensure_open!
48
- @model = value
49
- end
50
+ modify { @model = value }
50
51
  end
51
52
 
52
53
  def provider_response_id=(value)
53
- @mutex.synchronize do
54
- ensure_open!
55
- @provider_response_id = value
56
- end
54
+ modify { @provider_response_id = value }
57
55
  end
58
56
 
59
57
  def event(data, type: nil)
60
58
  data = SdkPayload.normalize(data) if data.is_a?(Hash)
61
- @mutex.synchronize do
62
- ensure_open!
63
- @window.push(data, type: type&.to_s) unless data.nil?
64
- end
59
+ modify { @window.push(data, type: type&.to_s) unless data.nil? }
65
60
  end
66
61
 
67
62
  def usage(input_tokens:, output_tokens:, **extra)
@@ -71,17 +66,10 @@ module LlmCostTracker
71
66
  "pass `pricing_mode: :batch` to track_stream"
72
67
  end
73
68
 
74
- @mutex.synchronize do
75
- ensure_open!
69
+ modify do
76
70
  @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
77
- @provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
78
- @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
79
- @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
80
- @explicit_usage = Usage::TokenUsage.build(
81
- **extra,
82
- input_tokens: input_tokens,
83
- output_tokens: output_tokens
84
- )
71
+ DIMENSIONS.each { |key| @dimensions[key] = extra.delete(key) || @dimensions[key] }
72
+ @explicit_usage = Usage::TokenUsage.build(**extra, input_tokens: input_tokens, output_tokens: output_tokens)
85
73
  end
86
74
  end
87
75
 
@@ -102,49 +90,57 @@ module LlmCostTracker
102
90
 
103
91
  private
104
92
 
93
+ def modify
94
+ @mutex.synchronize do
95
+ raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector" if @state == :finished
96
+
97
+ yield
98
+ end
99
+ end
100
+
101
+ def parser_window
102
+ parser = Parsers.find_for_provider(@parsed_as)
103
+ EventWindow.new(notable: parser&.method(:retain_stream_event?), trim: parser&.method(:trim_stream_event))
104
+ end
105
+
105
106
  def claim_recording_slot
106
107
  @mutex.synchronize do
107
- return nil if @finished || @recording
108
-
109
- @recording = true
110
- pricing_mode = Pricing::Mode.normalize(@pricing_mode)
111
- {
112
- events: @window.events,
113
- overflowed: @window.overflowed?,
114
- explicit_usage: @explicit_usage,
115
- model: @model,
116
- latency_ms: @latency_ms,
117
- provider_response_id: @provider_response_id,
118
- capture_dimensions: capture_dimensions,
119
- pricing_mode: pricing_mode,
120
- metadata: @metadata.deep_dup,
121
- context_tags: @context_tags.deep_dup,
122
- request: @request
123
- }
108
+ return nil unless @state == :open
109
+
110
+ @state = :recording
111
+ snapshot
124
112
  end
125
113
  end
126
114
 
127
- def record_snapshot(snapshot, errored:)
128
- save_succeeded = false
129
- begin
130
- event = build_event(snapshot)
131
- event = event.with(
132
- provider_response_id: event.provider_response_id || snapshot[:provider_response_id],
133
- pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot[:pricing_mode])
134
- )
115
+ def snapshot
116
+ pricing_mode = Pricing::Mode.normalize(@pricing_mode)
117
+ Snapshot.new(
118
+ events: @window.events,
119
+ overflowed: @window.overflowed?,
120
+ explicit_usage: @explicit_usage,
121
+ model: @model,
122
+ latency_ms: @latency_ms,
123
+ provider_response_id: @provider_response_id,
124
+ capture_dimensions: @dimensions.transform_values { |value| value.to_s.strip.presence }.compact,
125
+ pricing_mode: pricing_mode,
126
+ metadata: @metadata.deep_dup,
127
+ context_tags: @context_tags.deep_dup,
128
+ request: @request
129
+ )
130
+ end
135
131
 
136
- Tracker.record(
137
- event: event,
138
- latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
139
- metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
140
- context_tags: snapshot[:context_tags]
141
- ) { save_succeeded = true }
142
- ensure
143
- @mutex.synchronize do
144
- @finished = save_succeeded
145
- @recording = false
146
- release_buffers if save_succeeded
147
- end
132
+ def record_snapshot(snapshot, errored:)
133
+ saved = false
134
+ Tracker.record(
135
+ event: event_for(snapshot),
136
+ latency_ms: snapshot.latency_ms || LlmCostTracker::Timing.elapsed_ms(@started_at),
137
+ metadata: (errored ? { stream_errored: true } : {}).merge(snapshot.metadata),
138
+ context_tags: snapshot.context_tags
139
+ ) { saved = true }
140
+ ensure
141
+ @mutex.synchronize do
142
+ @state = saved ? :finished : :open
143
+ release_buffers if saved
148
144
  end
149
145
  end
150
146
 
@@ -153,42 +149,39 @@ module LlmCostTracker
153
149
  @request = nil
154
150
  end
155
151
 
156
- def capture_dimensions
157
- {
158
- provider_project_id: @provider_project_id.to_s.strip.presence,
159
- provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
160
- provider_workspace_id: @provider_workspace_id.to_s.strip.presence
161
- }.compact
152
+ def event_for(snapshot)
153
+ event = build_event(snapshot)
154
+ event.with(
155
+ provider_response_id: event.provider_response_id || snapshot.provider_response_id,
156
+ pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot.pricing_mode)
157
+ )
162
158
  end
163
159
 
164
- def ensure_open!
165
- return unless @finished
160
+ def build_event(snapshot)
161
+ return build_unparsed_event(snapshot) if snapshot.explicit_usage
162
+ return overflowed_event(snapshot) if snapshot.overflowed
166
163
 
167
- raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector"
168
- end
164
+ parsed = parse_events(snapshot)
165
+ return build_unparsed_event(snapshot) unless parsed
169
166
 
170
- def build_event(snapshot)
171
- return build_unparsed_event(snapshot) if snapshot[:explicit_usage]
167
+ model = present_model(parsed.model) || present_model(snapshot.model) || Event::UNKNOWN_MODEL
168
+ parsed.with(provider: @provider, model: model, **snapshot.capture_dimensions)
169
+ end
172
170
 
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
171
+ def overflowed_event(snapshot)
172
+ Logging.warn("#{@provider} stream events exceeded #{SSE::LIMIT_BYTES} bytes; " \
173
+ "recording usage_source=#{Usage::Source::UNKNOWN}.")
174
+ build_unparsed_event(snapshot)
175
+ end
178
176
 
179
- request_body = request_body_for(snapshot[:request])
180
- events = Parsers.all_for_provider(@provider).filter_map do |parser|
177
+ def parse_events(snapshot)
178
+ request_body = request_body_for(snapshot.request)
179
+ events = Parsers.all_for_provider(@parsed_as).filter_map do |parser|
181
180
  parser.parse_stream(
182
- response_status: 200, events: snapshot[:events], request_body: request_body, model: snapshot[:model]
181
+ response_status: 200, events: snapshot.events, request_body: request_body, model: snapshot.model
183
182
  )
184
183
  end
185
- event = events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
186
- if event
187
- model = present_model(event.model) || present_model(snapshot[:model]) || Event::UNKNOWN_MODEL
188
- return event.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
189
- end
190
-
191
- build_unparsed_event(snapshot)
184
+ events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
192
185
  end
193
186
 
194
187
  def request_body_for(request)
@@ -205,15 +198,15 @@ module LlmCostTracker
205
198
  end
206
199
 
207
200
  def build_unparsed_event(snapshot)
208
- explicit_usage = snapshot[:explicit_usage]
201
+ explicit_usage = snapshot.explicit_usage
209
202
  Event.build(
210
203
  provider: @provider,
211
- model: snapshot[:model] || Event::UNKNOWN_MODEL,
204
+ model: snapshot.model || Event::UNKNOWN_MODEL,
212
205
  token_usage: explicit_usage || Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
213
206
  stream: true,
214
207
  usage_source: explicit_usage ? Usage::Source::MANUAL : Usage::Source::UNKNOWN,
215
- pricing_mode: snapshot[:pricing_mode],
216
- **snapshot.fetch(:capture_dimensions)
208
+ pricing_mode: snapshot.pricing_mode,
209
+ **snapshot.capture_dimensions
217
210
  )
218
211
  end
219
212
  end
@@ -8,8 +8,8 @@ module LlmCostTracker
8
8
  class StreamTap
9
9
  MAX_PENDING_BYTES = 16 * 1_048_576
10
10
 
11
- def initialize(notable: nil)
12
- @window = EventWindow.new(notable: notable)
11
+ def initialize(notable: nil, trim: nil)
12
+ @window = EventWindow.new(notable: notable, trim: trim)
13
13
  @reader = SSE::Reader.new { |event| @window.push(event[:data], type: event[:event]) }
14
14
  @received = false
15
15
  @failed = false