llm_cost_tracker 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +25 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- metadata +37 -12
|
@@ -4,42 +4,94 @@ require "bigdecimal"
|
|
|
4
4
|
|
|
5
5
|
require_relative "ledger"
|
|
6
6
|
require_relative "pricing/estimator"
|
|
7
|
+
require_relative "budget/per_tag"
|
|
8
|
+
require_relative "tags/context"
|
|
7
9
|
|
|
8
10
|
module LlmCostTracker
|
|
9
11
|
module Budget
|
|
10
12
|
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
11
13
|
|
|
12
14
|
class << self
|
|
13
|
-
def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
|
|
15
|
+
def enforce!(provider: nil, model: nil, request: nil, estimate: nil, tags: nil, force: false)
|
|
14
16
|
config = LlmCostTracker.configuration
|
|
15
17
|
return unless config.enabled
|
|
16
|
-
|
|
18
|
+
|
|
19
|
+
globally = force || config.budgets.exceeded_behavior == :block_requests
|
|
20
|
+
per_tag = force || PerTag.blocking?
|
|
21
|
+
return unless globally || per_tag
|
|
17
22
|
|
|
18
23
|
estimate ||= estimate_cost(provider: provider, model: model, request: request)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
now = Time.now.utc
|
|
25
|
+
enforce_globally(config, estimate: estimate, time: now) if globally
|
|
26
|
+
return unless per_tag
|
|
27
|
+
|
|
28
|
+
check_per_tag(tags || Tags::Context.tags,
|
|
29
|
+
time: now,
|
|
30
|
+
estimate: estimate,
|
|
31
|
+
blocking_only: !force) do |rule, window, total, limit|
|
|
32
|
+
raise_pre_send(budget_type: window, total: total, budget: limit, scope: scope_for(rule))
|
|
27
33
|
end
|
|
28
34
|
end
|
|
29
35
|
|
|
30
|
-
def check!(event)
|
|
36
|
+
def check!(event, behavior_override: nil)
|
|
31
37
|
config = LlmCostTracker.configuration
|
|
32
38
|
return unless event.total_cost
|
|
33
39
|
|
|
34
|
-
check_per_call_budget(event, config)
|
|
35
|
-
check_windowed({ daily: config.
|
|
40
|
+
check_per_call_budget(event, config, behavior_override)
|
|
41
|
+
check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
|
|
36
42
|
time: event.tracked_at) do |budget_type, total, budget|
|
|
37
|
-
handle_exceeded(budget_type: budget_type,
|
|
43
|
+
handle_exceeded(budget_type: budget_type,
|
|
44
|
+
total: total,
|
|
45
|
+
budget: budget,
|
|
46
|
+
previous_total: total - event.total_cost,
|
|
47
|
+
last_event: event,
|
|
48
|
+
behavior: behavior_override)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_persisted!(events, behavior_override: nil)
|
|
53
|
+
by_rule = PerTag.rules_for_events(events.select(&:total_cost))
|
|
54
|
+
by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
|
|
55
|
+
window_buckets(by_rule).each do |(key, window, bucket), scored|
|
|
56
|
+
totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket)
|
|
57
|
+
scored.each do |rule, recorded|
|
|
58
|
+
total = totals.fetch(rule.value, 0).to_d
|
|
59
|
+
limit = rule.windows.fetch(window)
|
|
60
|
+
next if total < limit
|
|
61
|
+
|
|
62
|
+
handle_exceeded(
|
|
63
|
+
budget_type: window,
|
|
64
|
+
total: total,
|
|
65
|
+
budget: limit,
|
|
66
|
+
previous_total: total - recorded.sum(&:total_cost),
|
|
67
|
+
last_event: recorded.last,
|
|
68
|
+
scope: scope_for(rule),
|
|
69
|
+
behavior: behavior_override || rule.behavior,
|
|
70
|
+
on_exceeded: rule.on_exceeded
|
|
71
|
+
)
|
|
72
|
+
end
|
|
38
73
|
end
|
|
39
74
|
end
|
|
40
75
|
|
|
76
|
+
def notify_persisted_safely!(events)
|
|
77
|
+
check_persisted!(events, behavior_override: :notify)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
41
82
|
private
|
|
42
83
|
|
|
84
|
+
def window_buckets(by_rule)
|
|
85
|
+
by_rule.each_with_object({}) do |(rule, events), grouped|
|
|
86
|
+
rule.windows.each_key do |window|
|
|
87
|
+
events.group_by { |event| PerTag.window_start(window, event.tracked_at) }
|
|
88
|
+
.each do |bucket, bucket_events|
|
|
89
|
+
(grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
43
95
|
def estimate_cost(provider:, model:, request:)
|
|
44
96
|
return BigDecimal("0") unless provider && model && request
|
|
45
97
|
|
|
@@ -54,14 +106,48 @@ estimate: estimate) do |budget_type, total, budget|
|
|
|
54
106
|
))
|
|
55
107
|
end
|
|
56
108
|
|
|
57
|
-
def check_per_call_budget(event, config)
|
|
58
|
-
budget = config.
|
|
109
|
+
def check_per_call_budget(event, config, behavior_override)
|
|
110
|
+
budget = config.budgets.per_call
|
|
59
111
|
return unless budget
|
|
60
112
|
|
|
61
113
|
total = event.total_cost
|
|
62
114
|
return unless total >= budget
|
|
63
115
|
|
|
64
|
-
handle_exceeded(budget_type: :per_call,
|
|
116
|
+
handle_exceeded(budget_type: :per_call,
|
|
117
|
+
total: total,
|
|
118
|
+
budget: budget,
|
|
119
|
+
previous_total: nil,
|
|
120
|
+
last_event: event,
|
|
121
|
+
behavior: behavior_override)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def enforce_globally(config, estimate:, time:)
|
|
125
|
+
raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
|
|
126
|
+
|
|
127
|
+
check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
|
|
128
|
+
time: time,
|
|
129
|
+
estimate: estimate) do |budget_type, total, budget|
|
|
130
|
+
raise_pre_send(budget_type: budget_type, total: total, budget: budget)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def raise_pre_send(budget_type:, total:, budget:, scope: nil)
|
|
135
|
+
raise BudgetExceededError.new(**budget_payload(
|
|
136
|
+
budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send, scope: scope
|
|
137
|
+
))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
|
|
141
|
+
PerTag.rules_for(tags, blocking_only: blocking_only).each do |rule|
|
|
142
|
+
rule.windows.each do |window, limit|
|
|
143
|
+
total = PerTag.spend(rule.key, rule.value, window, time: time) + estimate
|
|
144
|
+
yield(rule, window, total, limit) if total >= limit
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def scope_for(rule)
|
|
150
|
+
{ key: rule.key, value: rule.value }
|
|
65
151
|
end
|
|
66
152
|
|
|
67
153
|
def check_windowed(budgets, time:, estimate: BigDecimal("0"))
|
|
@@ -82,38 +168,40 @@ estimate: estimate) do |budget_type, total, budget|
|
|
|
82
168
|
period_for.transform_values { |period| period_totals.fetch(period) }
|
|
83
169
|
end
|
|
84
170
|
|
|
85
|
-
def handle_exceeded(budget_type:,
|
|
171
|
+
def handle_exceeded(budget_type:,
|
|
172
|
+
total:,
|
|
173
|
+
budget:,
|
|
174
|
+
previous_total:,
|
|
175
|
+
last_event: nil,
|
|
176
|
+
scope: nil,
|
|
177
|
+
behavior: nil,
|
|
178
|
+
on_exceeded: nil)
|
|
86
179
|
config = LlmCostTracker.configuration
|
|
180
|
+
behavior ||= config.budgets.exceeded_behavior
|
|
181
|
+
on_exceeded ||= config.budgets.on_exceeded
|
|
87
182
|
payload = budget_payload(
|
|
88
183
|
budget_type: budget_type,
|
|
89
184
|
total: total,
|
|
90
185
|
budget: budget,
|
|
91
186
|
last_event: last_event,
|
|
92
|
-
stage: :post_spend
|
|
187
|
+
stage: :post_spend,
|
|
188
|
+
scope: scope
|
|
93
189
|
)
|
|
94
190
|
|
|
95
|
-
if
|
|
96
|
-
|
|
97
|
-
end
|
|
98
|
-
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
191
|
+
on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
|
|
192
|
+
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
|
|
99
193
|
end
|
|
100
194
|
|
|
101
|
-
def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
|
|
195
|
+
def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
|
|
102
196
|
{
|
|
103
197
|
budget_type: budget_type,
|
|
104
198
|
total: total,
|
|
105
199
|
budget: budget,
|
|
106
200
|
last_event: last_event,
|
|
107
|
-
stage: stage
|
|
201
|
+
stage: stage,
|
|
202
|
+
scope: scope
|
|
108
203
|
}
|
|
109
204
|
end
|
|
110
|
-
|
|
111
|
-
def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
|
|
112
|
-
return false unless config.on_budget_exceeded
|
|
113
|
-
return true if !last_event&.total_cost || budget_type == :per_call
|
|
114
|
-
|
|
115
|
-
total - last_event.total_cost < budget
|
|
116
|
-
end
|
|
117
205
|
end
|
|
118
206
|
end
|
|
119
207
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sse"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Capture
|
|
7
|
+
class EventWindow
|
|
8
|
+
HEAD_EVENTS = 16
|
|
9
|
+
TAIL_EVENTS = 32
|
|
10
|
+
HEAVY_STRING_BYTES = 8 * 1024
|
|
11
|
+
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(notable: nil)
|
|
14
|
+
@notable = notable
|
|
15
|
+
@head = []
|
|
16
|
+
@kept = []
|
|
17
|
+
@tail = []
|
|
18
|
+
@bytes = 0
|
|
19
|
+
@overflowed = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def push(data, type: nil)
|
|
23
|
+
return if @overflowed
|
|
24
|
+
|
|
25
|
+
event = { event: type, data: strip_heavy_payload(data) }
|
|
26
|
+
size = approximate_bytesize(event)
|
|
27
|
+
if @head.size < HEAD_EVENTS
|
|
28
|
+
@head << [event, size]
|
|
29
|
+
else
|
|
30
|
+
@tail << [event, size]
|
|
31
|
+
settle(@tail.shift) if @tail.size > TAIL_EVENTS
|
|
32
|
+
end
|
|
33
|
+
@bytes += size
|
|
34
|
+
overflow! if @bytes > SSE::LIMIT_BYTES
|
|
35
|
+
rescue TypeError, SystemStackError
|
|
36
|
+
overflow!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def events
|
|
40
|
+
(@head + @kept + @tail).map(&:first)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def overflowed?
|
|
44
|
+
@overflowed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def settle(entry)
|
|
50
|
+
return @kept << entry if notable?(entry.first[:data])
|
|
51
|
+
|
|
52
|
+
@bytes -= entry.last
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def notable?(data)
|
|
56
|
+
@notable&.call(data)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def overflow!
|
|
62
|
+
@overflowed = true
|
|
63
|
+
@head = []
|
|
64
|
+
@kept = []
|
|
65
|
+
@tail = []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def strip_heavy_payload(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash
|
|
71
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
72
|
+
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
73
|
+
|
|
74
|
+
out[key] = strip_heavy_payload(nested)
|
|
75
|
+
end
|
|
76
|
+
when Array
|
|
77
|
+
value.map { |nested| strip_heavy_payload(nested) }
|
|
78
|
+
when String
|
|
79
|
+
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
80
|
+
else
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def approximate_bytesize(value)
|
|
86
|
+
case value
|
|
87
|
+
when Hash
|
|
88
|
+
value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
|
|
89
|
+
when Array
|
|
90
|
+
value.sum { |nested| approximate_bytesize(nested) + 2 }
|
|
91
|
+
when Numeric, true, false, nil
|
|
92
|
+
8
|
|
93
|
+
else
|
|
94
|
+
value.to_s.bytesize + 2
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -11,52 +11,83 @@ module LlmCostTracker
|
|
|
11
11
|
|
|
12
12
|
class << self
|
|
13
13
|
def parse(body)
|
|
14
|
-
|
|
14
|
+
events = []
|
|
15
|
+
reader = Reader.new { |event| events << event }
|
|
16
|
+
reader << body
|
|
17
|
+
reader.finish
|
|
18
|
+
events
|
|
19
|
+
end
|
|
20
|
+
end
|
|
15
21
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
def pending_bytesize
|
|
45
|
+
@pending.bytesize
|
|
46
|
+
end
|
|
22
47
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
current_event = nil
|
|
26
|
-
data_lines = []
|
|
48
|
+
def finish
|
|
49
|
+
return unless @mode == :sse
|
|
27
50
|
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
consume_line(@pending) unless @pending.empty?
|
|
52
|
+
@pending = String.new(encoding: Encoding::BINARY)
|
|
53
|
+
dispatch
|
|
54
|
+
end
|
|
30
55
|
|
|
31
|
-
|
|
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
|
-
|
|
58
|
+
def detect_mode
|
|
59
|
+
first = @pending[/\S/]
|
|
60
|
+
return nil unless first
|
|
39
61
|
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
first == "[" ? :array : :sse
|
|
63
|
+
end
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
return
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
field, _, value = line.partition(":")
|
|
80
|
+
value = value[1..] if value.start_with?(" ")
|
|
81
|
+
case field
|
|
82
|
+
when "event" then @event_name = value
|
|
83
|
+
when "data" then @data_lines << value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dispatch
|
|
88
|
+
emit(finalize_event(@event_name, @data_lines)) if @data_lines.any?
|
|
89
|
+
@event_name = nil
|
|
90
|
+
@data_lines = []
|
|
60
91
|
end
|
|
61
92
|
|
|
62
93
|
def finalize_event(event_name, data_lines)
|
|
@@ -73,6 +104,51 @@ module LlmCostTracker
|
|
|
73
104
|
rescue JSON::ParserError
|
|
74
105
|
payload
|
|
75
106
|
end
|
|
107
|
+
|
|
108
|
+
def consume_array
|
|
109
|
+
pos = @scan_pos
|
|
110
|
+
while (index = @pending.index(@in_string ? /["\\]/ : /[{}"]/, pos))
|
|
111
|
+
pos = index + 1
|
|
112
|
+
pos = advance_array(@pending.getbyte(index).chr, index, pos)
|
|
113
|
+
end
|
|
114
|
+
trim_array_buffer(pos)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def advance_array(char, index, pos)
|
|
118
|
+
if @in_string
|
|
119
|
+
return pos + 1 if char == "\\"
|
|
120
|
+
|
|
121
|
+
@in_string = false
|
|
122
|
+
elsif char == '"'
|
|
123
|
+
@in_string = true
|
|
124
|
+
elsif char == "{"
|
|
125
|
+
@object_start = index if @depth.zero?
|
|
126
|
+
@depth += 1
|
|
127
|
+
elsif @depth.positive?
|
|
128
|
+
@depth -= 1
|
|
129
|
+
emit_object(index) if @depth.zero?
|
|
130
|
+
end
|
|
131
|
+
pos
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def emit_object(end_index)
|
|
135
|
+
text = @pending.byteslice(@object_start, end_index - @object_start + 1)
|
|
136
|
+
@object_start = nil
|
|
137
|
+
emit({ event: nil, data: JSON.parse(text.force_encoding(Encoding::UTF_8)) })
|
|
138
|
+
rescue JSON::ParserError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def trim_array_buffer(pos)
|
|
143
|
+
keep_from = @object_start || [pos, @pending.bytesize].min
|
|
144
|
+
@pending = @pending.byteslice(keep_from..)
|
|
145
|
+
@scan_pos = pos - keep_from
|
|
146
|
+
@object_start &&= 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def emit(event)
|
|
150
|
+
@on_event.call(event) if event
|
|
151
|
+
end
|
|
76
152
|
end
|
|
77
153
|
end
|
|
78
154
|
end
|
|
@@ -4,7 +4,7 @@ require "active_support/core_ext/object/blank"
|
|
|
4
4
|
require "active_support/core_ext/object/deep_dup"
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "event_window"
|
|
8
8
|
require_relative "../timing"
|
|
9
9
|
|
|
10
10
|
module LlmCostTracker
|
|
@@ -34,9 +34,7 @@ module LlmCostTracker
|
|
|
34
34
|
@metadata = (metadata || {}).deep_dup
|
|
35
35
|
@context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
|
|
36
36
|
@request = request
|
|
37
|
-
@
|
|
38
|
-
@captured_bytes = 0
|
|
39
|
-
@overflowed = false
|
|
37
|
+
@window = EventWindow.new(notable: Parsers.find_for_provider(@provider)&.method(:retain_stream_event?))
|
|
40
38
|
@explicit_usage = nil
|
|
41
39
|
@started_at = LlmCostTracker::Timing.now_monotonic
|
|
42
40
|
@finished = false
|
|
@@ -61,7 +59,7 @@ module LlmCostTracker
|
|
|
61
59
|
def event(data, type: nil)
|
|
62
60
|
@mutex.synchronize do
|
|
63
61
|
ensure_open!
|
|
64
|
-
|
|
62
|
+
@window.push(data, type: type) unless data.nil?
|
|
65
63
|
end
|
|
66
64
|
end
|
|
67
65
|
|
|
@@ -91,6 +89,12 @@ module LlmCostTracker
|
|
|
91
89
|
return if snapshot.nil?
|
|
92
90
|
|
|
93
91
|
record_snapshot(snapshot, errored: errored)
|
|
92
|
+
rescue TransactionAbortedError
|
|
93
|
+
raise
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
raise unless errored
|
|
96
|
+
|
|
97
|
+
Logging.warn("Recording an errored stream raised #{e.class}: #{e.message}; kept the stream's own exception")
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
private
|
|
@@ -102,8 +106,8 @@ module LlmCostTracker
|
|
|
102
106
|
@recording = true
|
|
103
107
|
pricing_mode = Pricing::Mode.normalize(@pricing_mode)
|
|
104
108
|
{
|
|
105
|
-
events: @events
|
|
106
|
-
overflowed: @overflowed
|
|
109
|
+
events: @window.events,
|
|
110
|
+
overflowed: @window.overflowed?,
|
|
107
111
|
explicit_usage: @explicit_usage,
|
|
108
112
|
model: @model,
|
|
109
113
|
latency_ms: @latency_ms,
|
|
@@ -136,10 +140,16 @@ module LlmCostTracker
|
|
|
136
140
|
@mutex.synchronize do
|
|
137
141
|
@finished = save_succeeded
|
|
138
142
|
@recording = false
|
|
143
|
+
release_buffers if save_succeeded
|
|
139
144
|
end
|
|
140
145
|
end
|
|
141
146
|
end
|
|
142
147
|
|
|
148
|
+
def release_buffers
|
|
149
|
+
@window = EventWindow.new
|
|
150
|
+
@request = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
143
153
|
def capture_dimensions
|
|
144
154
|
{
|
|
145
155
|
provider_project_id: @provider_project_id.to_s.strip.presence,
|
|
@@ -207,55 +217,6 @@ module LlmCostTracker
|
|
|
207
217
|
**snapshot.fetch(:capture_dimensions)
|
|
208
218
|
)
|
|
209
219
|
end
|
|
210
|
-
|
|
211
|
-
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
212
|
-
private_constant :IGNORED_PAYLOAD_KEYS
|
|
213
|
-
|
|
214
|
-
HEAVY_STRING_BYTES = 8 * 1024
|
|
215
|
-
private_constant :HEAVY_STRING_BYTES
|
|
216
|
-
|
|
217
|
-
def capture_event(data, type:)
|
|
218
|
-
event = { event: type, data: strip_heavy_payload(data) }
|
|
219
|
-
size = approximate_bytesize(event)
|
|
220
|
-
if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
|
|
221
|
-
@events << event
|
|
222
|
-
@captured_bytes += size
|
|
223
|
-
else
|
|
224
|
-
@overflowed = true
|
|
225
|
-
end
|
|
226
|
-
rescue TypeError, SystemStackError
|
|
227
|
-
@overflowed = true
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def strip_heavy_payload(value)
|
|
231
|
-
case value
|
|
232
|
-
when Hash
|
|
233
|
-
value.each_with_object({}) do |(key, nested), out|
|
|
234
|
-
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
235
|
-
|
|
236
|
-
out[key] = strip_heavy_payload(nested)
|
|
237
|
-
end
|
|
238
|
-
when Array
|
|
239
|
-
value.map { |nested| strip_heavy_payload(nested) }
|
|
240
|
-
when String
|
|
241
|
-
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
242
|
-
else
|
|
243
|
-
value
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def approximate_bytesize(value)
|
|
248
|
-
case value
|
|
249
|
-
when Hash
|
|
250
|
-
value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
|
|
251
|
-
when Array
|
|
252
|
-
value.sum { |nested| approximate_bytesize(nested) + 2 }
|
|
253
|
-
when Numeric, true, false, nil
|
|
254
|
-
8
|
|
255
|
-
else
|
|
256
|
-
value.to_s.bytesize + 2
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
220
|
end
|
|
260
221
|
end
|
|
261
222
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "event_window"
|
|
4
|
+
require_relative "sse"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Capture
|
|
8
|
+
class StreamTap
|
|
9
|
+
MAX_PENDING_BYTES = 16 * 1_048_576
|
|
10
|
+
|
|
11
|
+
def initialize(notable: nil)
|
|
12
|
+
@window = EventWindow.new(notable: notable)
|
|
13
|
+
@reader = SSE::Reader.new { |event| @window.push(event[:data], type: event[:event]) }
|
|
14
|
+
@received = false
|
|
15
|
+
@failed = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def <<(chunk)
|
|
19
|
+
return if @failed
|
|
20
|
+
|
|
21
|
+
@received = true
|
|
22
|
+
@reader << chunk
|
|
23
|
+
fail! if @reader.pending_bytesize > MAX_PENDING_BYTES
|
|
24
|
+
rescue StandardError
|
|
25
|
+
fail!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def received?
|
|
29
|
+
@received
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failed?
|
|
33
|
+
@failed
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def overflowed?
|
|
37
|
+
@window.overflowed?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def events
|
|
41
|
+
@reader&.finish
|
|
42
|
+
@failed ? [] : @window.events
|
|
43
|
+
rescue StandardError
|
|
44
|
+
fail!
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def fail!
|
|
51
|
+
@failed = true
|
|
52
|
+
@reader = nil
|
|
53
|
+
@window = EventWindow.new
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|