llm_cost_tracker 0.14.1 → 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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -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,57 +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
- 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
- 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
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?
27
+ raise_first(errors)
50
28
  end
51
29
 
52
30
  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
73
- end
31
+ raise_first(Tagged.post_spend_errors(events, behavior_override))
74
32
  end
75
33
 
76
34
  def notify_persisted_safely!(events)
@@ -79,128 +37,21 @@ module LlmCostTracker
79
37
  Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
80
38
  end
81
39
 
82
- private
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
-
95
- def estimate_cost(provider:, model:, request:)
96
- return BigDecimal("0") unless provider && model && request
97
-
98
- Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
99
- end
100
-
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
- def check_per_call_budget(event, config, behavior_override)
110
- budget = config.budgets.per_call
111
- return unless budget
112
-
113
- total = event.total_cost
114
- return unless total >= budget
115
-
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 }
151
- end
152
-
153
- def check_windowed(budgets, time:, estimate: BigDecimal("0"))
154
- return if budgets.empty?
40
+ def notify_repriced_safely!(changes)
41
+ return if changes.empty?
155
42
 
156
- totals = totals_for(budgets.keys, time: time)
157
- budgets.each do |budget_type, budget|
158
- total = totals.fetch(budget_type) + estimate
159
- yield(budget_type, total, budget) if total >= budget
160
- end
161
- end
162
-
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) }
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}")
169
48
  end
170
49
 
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 = budget_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
- raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
193
- end
50
+ private
194
51
 
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
- }
52
+ def raise_first(errors)
53
+ error = errors.compact.first
54
+ raise error if error
204
55
  end
205
56
  end
206
57
  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].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,18 +21,10 @@ module LlmCostTracker
20
21
  end
21
22
 
22
23
  def push(data, type: nil)
23
- return if @overflowed
24
+ return if @overflowed || type&.start_with?("logprobs.")
24
25
 
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
26
+ event = { event: type, data: strip_heavy_payload(@trim ? @trim.call(data) : data) }
27
+ append([event, approximate_bytesize(event)])
35
28
  rescue TypeError, SystemStackError
36
29
  overflow!
37
30
  end
@@ -46,6 +39,17 @@ module LlmCostTracker
46
39
 
47
40
  private
48
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
+
49
53
  def settle(entry)
50
54
  return @kept << entry if notable?(entry.first[:data])
51
55
 
@@ -3,31 +3,40 @@
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
9
- 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
10
21
 
11
- def normalize(value)
12
- case value
13
- when Hash
14
- value.each_with_object({}) { |(key, nested), out| out[key.to_s] = normalize(nested) }
15
- when Array
16
- value.map { |nested| normalize(nested) }
17
- when Symbol
18
- value.to_s
19
- when NilClass
20
- nil
21
- else
22
+ private
23
+
24
+ def normalize_hash(hash)
25
+ hash.each_with_object({}) do |(key, nested), out|
26
+ out[key.to_s] = normalize(nested) unless EventWindow::IGNORED_PAYLOAD_KEYS.include?(key.to_s)
27
+ end
28
+ end
29
+
30
+ def normalize_object(value)
22
31
  converted = container_for(value)
23
32
  converted ? normalize(converted) : value.deep_dup
24
33
  end
25
- end
26
34
 
27
- def container_for(value)
28
- value.try(:deep_to_h) || value.try(:to_h)
29
- rescue StandardError
30
- nil
35
+ def container_for(value)
36
+ value.try(:to_h)
37
+ rescue StandardError
38
+ nil
39
+ end
31
40
  end
32
41
  end
33
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
 
@@ -5,15 +5,22 @@ require "active_support/core_ext/object/deep_dup"
5
5
  require "json"
6
6
 
7
7
  require_relative "event_window"
8
+ require_relative "sdk_payload"
8
9
  require_relative "../timing"
9
10
 
10
11
  module LlmCostTracker
11
12
  module Capture
12
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
+
13
19
  attr_reader :provider
14
20
 
15
21
  def initialize(provider:,
16
22
  model:,
23
+ parsed_as: provider,
17
24
  latency_ms: nil,
18
25
  provider_response_id: nil,
19
26
  provider_project_id: nil,
@@ -21,46 +28,35 @@ module LlmCostTracker
21
28
  provider_workspace_id: nil,
22
29
  pricing_mode: nil,
23
30
  metadata: {},
24
- context_tags: nil,
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
- @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
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
- @mutex.synchronize do
61
- ensure_open!
62
- @window.push(data, type: type) unless data.nil?
63
- end
58
+ data = SdkPayload.normalize(data) if data.is_a?(Hash)
59
+ modify { @window.push(data, type: type&.to_s) unless data.nil? }
64
60
  end
65
61
 
66
62
  def usage(input_tokens:, output_tokens:, **extra)
@@ -70,17 +66,10 @@ module LlmCostTracker
70
66
  "pass `pricing_mode: :batch` to track_stream"
71
67
  end
72
68
 
73
- @mutex.synchronize do
74
- ensure_open!
69
+ modify do
75
70
  @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
76
- @provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
77
- @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
78
- @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
79
- @explicit_usage = Usage::TokenUsage.build(
80
- **extra,
81
- input_tokens: input_tokens,
82
- output_tokens: output_tokens
83
- )
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)
84
73
  end
85
74
  end
86
75
 
@@ -91,6 +80,8 @@ module LlmCostTracker
91
80
  record_snapshot(snapshot, errored: errored)
92
81
  rescue TransactionAbortedError
93
82
  raise
83
+ rescue ActiveRecord::RecordNotUnique
84
+ nil
94
85
  rescue StandardError => e
95
86
  raise unless errored
96
87
 
@@ -99,49 +90,57 @@ module LlmCostTracker
99
90
 
100
91
  private
101
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
+
102
106
  def claim_recording_slot
103
107
  @mutex.synchronize do
104
- return nil if @finished || @recording
105
-
106
- @recording = true
107
- pricing_mode = Pricing::Mode.normalize(@pricing_mode)
108
- {
109
- events: @window.events,
110
- overflowed: @window.overflowed?,
111
- explicit_usage: @explicit_usage,
112
- model: @model,
113
- latency_ms: @latency_ms,
114
- provider_response_id: @provider_response_id,
115
- capture_dimensions: capture_dimensions,
116
- pricing_mode: pricing_mode,
117
- metadata: @metadata.deep_dup,
118
- context_tags: @context_tags.deep_dup,
119
- request: @request
120
- }
108
+ return nil unless @state == :open
109
+
110
+ @state = :recording
111
+ snapshot
121
112
  end
122
113
  end
123
114
 
124
- def record_snapshot(snapshot, errored:)
125
- save_succeeded = false
126
- begin
127
- event = build_event(snapshot)
128
- event = event.with(
129
- provider_response_id: event.provider_response_id || snapshot[:provider_response_id],
130
- pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot[:pricing_mode])
131
- )
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
132
131
 
133
- Tracker.record(
134
- event: event,
135
- latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
136
- metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
137
- context_tags: snapshot[:context_tags]
138
- ) { save_succeeded = true }
139
- ensure
140
- @mutex.synchronize do
141
- @finished = save_succeeded
142
- @recording = false
143
- release_buffers if save_succeeded
144
- 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
145
144
  end
146
145
  end
147
146
 
@@ -150,35 +149,39 @@ module LlmCostTracker
150
149
  @request = nil
151
150
  end
152
151
 
153
- def capture_dimensions
154
- {
155
- provider_project_id: @provider_project_id.to_s.strip.presence,
156
- provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
157
- provider_workspace_id: @provider_workspace_id.to_s.strip.presence
158
- }.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
+ )
159
158
  end
160
159
 
161
- def ensure_open!
162
- 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
163
+
164
+ parsed = parse_events(snapshot)
165
+ return build_unparsed_event(snapshot) unless parsed
163
166
 
164
- raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector"
167
+ model = present_model(parsed.model) || present_model(snapshot.model) || Event::UNKNOWN_MODEL
168
+ parsed.with(provider: @provider, model: model, **snapshot.capture_dimensions)
165
169
  end
166
170
 
167
- def build_event(snapshot)
168
- return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
169
- return build_unknown_usage(snapshot) if snapshot[:overflowed]
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
170
176
 
171
- event = Parsers.find_for_provider(@provider)&.parse_stream(
172
- response_status: 200,
173
- events: snapshot[:events],
174
- request_body: request_body_for(snapshot[:request])
175
- )
176
- if event
177
- model = present_model(event.model) || present_model(snapshot[:model]) || Event::UNKNOWN_MODEL
178
- return event.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
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|
180
+ parser.parse_stream(
181
+ response_status: 200, events: snapshot.events, request_body: request_body, model: snapshot.model
182
+ )
179
183
  end
180
-
181
- build_unknown_usage(snapshot)
184
+ events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
182
185
  end
183
186
 
184
187
  def request_body_for(request)
@@ -194,27 +197,16 @@ module LlmCostTracker
194
197
  string unless string == Event::UNKNOWN_MODEL
195
198
  end
196
199
 
197
- def build_from_explicit_usage(snapshot)
198
- Event.build(
199
- provider: @provider,
200
- model: snapshot[:model] || Event::UNKNOWN_MODEL,
201
- token_usage: snapshot[:explicit_usage],
202
- stream: true,
203
- usage_source: Usage::Source::MANUAL,
204
- pricing_mode: snapshot[:pricing_mode],
205
- **snapshot.fetch(:capture_dimensions)
206
- )
207
- end
208
-
209
- def build_unknown_usage(snapshot)
200
+ def build_unparsed_event(snapshot)
201
+ explicit_usage = snapshot.explicit_usage
210
202
  Event.build(
211
203
  provider: @provider,
212
- model: snapshot[:model] || Event::UNKNOWN_MODEL,
213
- token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
204
+ model: snapshot.model || Event::UNKNOWN_MODEL,
205
+ token_usage: explicit_usage || Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
214
206
  stream: true,
215
- usage_source: Usage::Source::UNKNOWN,
216
- pricing_mode: snapshot[:pricing_mode],
217
- **snapshot.fetch(:capture_dimensions)
207
+ usage_source: explicit_usage ? Usage::Source::MANUAL : Usage::Source::UNKNOWN,
208
+ pricing_mode: snapshot.pricing_mode,
209
+ **snapshot.capture_dimensions
218
210
  )
219
211
  end
220
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