llm_cost_tracker 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
- data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
- data/lib/llm_cost_tracker/integrations/base.rb +56 -19
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +14 -13
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
- data/lib/llm_cost_tracker/parsers.rb +12 -41
- data/lib/llm_cost_tracker/prices.json +3071 -249
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +17 -10
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +26 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +23 -10
|
@@ -37,25 +37,40 @@ module LlmCostTracker
|
|
|
37
37
|
config = LlmCostTracker.configuration
|
|
38
38
|
return unless event.total_cost
|
|
39
39
|
|
|
40
|
-
check_per_call_budget(event, config, behavior_override)
|
|
40
|
+
errors = [check_per_call_budget(event, config, behavior_override)]
|
|
41
41
|
check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
|
|
42
42
|
time: event.tracked_at) do |budget_type, total, budget|
|
|
43
|
-
handle_exceeded(budget_type: budget_type,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
errors << handle_exceeded(budget_type: budget_type,
|
|
44
|
+
total: total,
|
|
45
|
+
budget: budget,
|
|
46
|
+
previous_total: total - event.total_cost,
|
|
47
|
+
last_event: event,
|
|
48
|
+
behavior: behavior_override)
|
|
49
49
|
end
|
|
50
|
+
errors.concat(persisted_errors([event], behavior_override)) unless Ingestion.async?
|
|
51
|
+
raise_first(errors)
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
def check_persisted!(events, behavior_override: nil)
|
|
53
|
-
|
|
55
|
+
raise_first(persisted_errors(events, behavior_override))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def notify_persisted_safely!(events)
|
|
59
|
+
check_persisted!(events, behavior_override: :notify)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def persisted_errors(events, behavior_override)
|
|
67
|
+
by_rule = PerTag.rules_for_events(events)
|
|
54
68
|
by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
|
|
55
|
-
window_buckets(by_rule).
|
|
56
|
-
|
|
57
|
-
scored.
|
|
58
|
-
|
|
69
|
+
window_buckets(by_rule).flat_map do |(key, window, bucket), scored|
|
|
70
|
+
upto = scored.values.flatten.map(&:tracked_at).max unless Ingestion.async?
|
|
71
|
+
totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket, upto)
|
|
72
|
+
scored.filter_map do |rule, recorded|
|
|
73
|
+
total, upto_total = totals.fetch(rule.value, [0, 0])
|
|
59
74
|
limit = rule.windows.fetch(window)
|
|
60
75
|
next if total < limit
|
|
61
76
|
|
|
@@ -63,7 +78,7 @@ module LlmCostTracker
|
|
|
63
78
|
budget_type: window,
|
|
64
79
|
total: total,
|
|
65
80
|
budget: limit,
|
|
66
|
-
previous_total: total - recorded.sum(&:total_cost),
|
|
81
|
+
previous_total: (upto_total >= limit ? upto_total : total) - recorded.sum(&:total_cost),
|
|
67
82
|
last_event: recorded.last,
|
|
68
83
|
scope: scope_for(rule),
|
|
69
84
|
behavior: behavior_override || rule.behavior,
|
|
@@ -73,14 +88,11 @@ module LlmCostTracker
|
|
|
73
88
|
end
|
|
74
89
|
end
|
|
75
90
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
|
|
91
|
+
def raise_first(errors)
|
|
92
|
+
error = errors.compact.first
|
|
93
|
+
raise error if error
|
|
80
94
|
end
|
|
81
95
|
|
|
82
|
-
private
|
|
83
|
-
|
|
84
96
|
def window_buckets(by_rule)
|
|
85
97
|
by_rule.each_with_object({}) do |(rule, events), grouped|
|
|
86
98
|
rule.windows.each_key do |window|
|
|
@@ -98,14 +110,6 @@ module LlmCostTracker
|
|
|
98
110
|
Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
|
|
99
111
|
end
|
|
100
112
|
|
|
101
|
-
def raise_per_call_pre_send(estimate, budget)
|
|
102
|
-
return unless estimate >= budget
|
|
103
|
-
|
|
104
|
-
raise BudgetExceededError.new(**budget_payload(
|
|
105
|
-
budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
|
|
106
|
-
))
|
|
107
|
-
end
|
|
108
|
-
|
|
109
113
|
def check_per_call_budget(event, config, behavior_override)
|
|
110
114
|
budget = config.budgets.per_call
|
|
111
115
|
return unless budget
|
|
@@ -122,7 +126,10 @@ module LlmCostTracker
|
|
|
122
126
|
end
|
|
123
127
|
|
|
124
128
|
def enforce_globally(config, estimate:, time:)
|
|
125
|
-
|
|
129
|
+
per_call = config.budgets.per_call
|
|
130
|
+
if per_call && estimate.positive? && estimate >= per_call
|
|
131
|
+
raise_pre_send(budget_type: :per_call, total: estimate, budget: per_call)
|
|
132
|
+
end
|
|
126
133
|
|
|
127
134
|
check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
|
|
128
135
|
time: time,
|
|
@@ -132,9 +139,9 @@ module LlmCostTracker
|
|
|
132
139
|
end
|
|
133
140
|
|
|
134
141
|
def raise_pre_send(budget_type:, total:, budget:, scope: nil)
|
|
135
|
-
raise BudgetExceededError.new(
|
|
136
|
-
budget_type: budget_type, total: total, budget: budget,
|
|
137
|
-
)
|
|
142
|
+
raise BudgetExceededError.new(
|
|
143
|
+
budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
|
|
144
|
+
)
|
|
138
145
|
end
|
|
139
146
|
|
|
140
147
|
def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
|
|
@@ -153,21 +160,14 @@ module LlmCostTracker
|
|
|
153
160
|
def check_windowed(budgets, time:, estimate: BigDecimal("0"))
|
|
154
161
|
return if budgets.empty?
|
|
155
162
|
|
|
156
|
-
|
|
163
|
+
periods = budgets.keys.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
|
|
164
|
+
totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
|
|
157
165
|
budgets.each do |budget_type, budget|
|
|
158
|
-
total = totals.fetch(budget_type) + estimate
|
|
166
|
+
total = totals.fetch(BUDGET_TYPE_TO_PERIOD.fetch(budget_type)) + estimate
|
|
159
167
|
yield(budget_type, total, budget) if total >= budget
|
|
160
168
|
end
|
|
161
169
|
end
|
|
162
170
|
|
|
163
|
-
def totals_for(budget_types, time:)
|
|
164
|
-
return {} if budget_types.empty?
|
|
165
|
-
|
|
166
|
-
period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
|
|
167
|
-
period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
|
|
168
|
-
period_for.transform_values { |period| period_totals.fetch(period) }
|
|
169
|
-
end
|
|
170
|
-
|
|
171
171
|
def handle_exceeded(budget_type:,
|
|
172
172
|
total:,
|
|
173
173
|
budget:,
|
|
@@ -179,28 +179,17 @@ module LlmCostTracker
|
|
|
179
179
|
config = LlmCostTracker.configuration
|
|
180
180
|
behavior ||= config.budgets.exceeded_behavior
|
|
181
181
|
on_exceeded ||= config.budgets.on_exceeded
|
|
182
|
-
payload =
|
|
182
|
+
payload = {
|
|
183
183
|
budget_type: budget_type,
|
|
184
184
|
total: total,
|
|
185
185
|
budget: budget,
|
|
186
186
|
last_event: last_event,
|
|
187
187
|
stage: :post_spend,
|
|
188
188
|
scope: scope
|
|
189
|
-
|
|
189
|
+
}
|
|
190
190
|
|
|
191
191
|
on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
|
|
192
|
-
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
|
|
196
|
-
{
|
|
197
|
-
budget_type: budget_type,
|
|
198
|
-
total: total,
|
|
199
|
-
budget: budget,
|
|
200
|
-
last_event: last_event,
|
|
201
|
-
stage: stage,
|
|
202
|
-
scope: scope
|
|
203
|
-
}
|
|
192
|
+
BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
|
|
204
193
|
end
|
|
205
194
|
end
|
|
206
195
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sse"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Capture
|
|
7
|
+
class EventWindow
|
|
8
|
+
HEAD_EVENTS = 16
|
|
9
|
+
TAIL_EVENTS = 32
|
|
10
|
+
HEAVY_STRING_BYTES = 8 * 1024
|
|
11
|
+
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64 snapshot logprobs].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(notable: nil)
|
|
14
|
+
@notable = notable
|
|
15
|
+
@head = []
|
|
16
|
+
@kept = []
|
|
17
|
+
@tail = []
|
|
18
|
+
@bytes = 0
|
|
19
|
+
@overflowed = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def push(data, type: nil)
|
|
23
|
+
# The openai gem's chat stream helper resends all logprobs so far in logprobs.* events; none carry usage.
|
|
24
|
+
return if @overflowed || type&.start_with?("logprobs.")
|
|
25
|
+
|
|
26
|
+
event = { event: type, data: strip_heavy_payload(data) }
|
|
27
|
+
size = approximate_bytesize(event)
|
|
28
|
+
if @head.size < HEAD_EVENTS
|
|
29
|
+
@head << [event, size]
|
|
30
|
+
else
|
|
31
|
+
@tail << [event, size]
|
|
32
|
+
settle(@tail.shift) if @tail.size > TAIL_EVENTS
|
|
33
|
+
end
|
|
34
|
+
@bytes += size
|
|
35
|
+
overflow! if @bytes > SSE::LIMIT_BYTES
|
|
36
|
+
rescue TypeError, SystemStackError
|
|
37
|
+
overflow!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def events
|
|
41
|
+
(@head + @kept + @tail).map(&:first)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def overflowed?
|
|
45
|
+
@overflowed
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def settle(entry)
|
|
51
|
+
return @kept << entry if notable?(entry.first[:data])
|
|
52
|
+
|
|
53
|
+
@bytes -= entry.last
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def notable?(data)
|
|
57
|
+
@notable&.call(data)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def overflow!
|
|
63
|
+
@overflowed = true
|
|
64
|
+
@head = []
|
|
65
|
+
@kept = []
|
|
66
|
+
@tail = []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def strip_heavy_payload(value)
|
|
70
|
+
case value
|
|
71
|
+
when Hash
|
|
72
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
73
|
+
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
74
|
+
|
|
75
|
+
out[key] = strip_heavy_payload(nested)
|
|
76
|
+
end
|
|
77
|
+
when Array
|
|
78
|
+
value.map { |nested| strip_heavy_payload(nested) }
|
|
79
|
+
when String
|
|
80
|
+
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
81
|
+
else
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def approximate_bytesize(value)
|
|
87
|
+
case value
|
|
88
|
+
when Hash
|
|
89
|
+
value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
|
|
90
|
+
when Array
|
|
91
|
+
value.sum { |nested| approximate_bytesize(nested) + 2 }
|
|
92
|
+
when Numeric, true, false, nil
|
|
93
|
+
8
|
|
94
|
+
else
|
|
95
|
+
value.to_s.bytesize + 2
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "active_support/core_ext/object/deep_dup"
|
|
4
4
|
require "active_support/core_ext/object/try"
|
|
5
5
|
|
|
6
|
+
require_relative "event_window"
|
|
7
|
+
|
|
6
8
|
module LlmCostTracker
|
|
7
9
|
module Capture
|
|
8
10
|
module SdkPayload
|
|
@@ -11,7 +13,9 @@ module LlmCostTracker
|
|
|
11
13
|
def normalize(value)
|
|
12
14
|
case value
|
|
13
15
|
when Hash
|
|
14
|
-
value.each_with_object({})
|
|
16
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
17
|
+
out[key.to_s] = normalize(nested) unless EventWindow::IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
18
|
+
end
|
|
15
19
|
when Array
|
|
16
20
|
value.map { |nested| normalize(nested) }
|
|
17
21
|
when Symbol
|
|
@@ -11,52 +11,83 @@ module LlmCostTracker
|
|
|
11
11
|
|
|
12
12
|
class << self
|
|
13
13
|
def parse(body)
|
|
14
|
-
|
|
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,8 @@ require "active_support/core_ext/object/blank"
|
|
|
4
4
|
require "active_support/core_ext/object/deep_dup"
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "event_window"
|
|
8
|
+
require_relative "sdk_payload"
|
|
8
9
|
require_relative "../timing"
|
|
9
10
|
|
|
10
11
|
module LlmCostTracker
|
|
@@ -21,7 +22,6 @@ module LlmCostTracker
|
|
|
21
22
|
provider_workspace_id: nil,
|
|
22
23
|
pricing_mode: nil,
|
|
23
24
|
metadata: {},
|
|
24
|
-
context_tags: nil,
|
|
25
25
|
request: nil)
|
|
26
26
|
@provider = provider.to_s
|
|
27
27
|
@model = model
|
|
@@ -32,11 +32,9 @@ module LlmCostTracker
|
|
|
32
32
|
@provider_workspace_id = provider_workspace_id
|
|
33
33
|
@pricing_mode = pricing_mode
|
|
34
34
|
@metadata = (metadata || {}).deep_dup
|
|
35
|
-
@context_tags =
|
|
35
|
+
@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
|
|
@@ -59,9 +57,10 @@ module LlmCostTracker
|
|
|
59
57
|
end
|
|
60
58
|
|
|
61
59
|
def event(data, type: nil)
|
|
60
|
+
data = SdkPayload.normalize(data) if data.is_a?(Hash)
|
|
62
61
|
@mutex.synchronize do
|
|
63
62
|
ensure_open!
|
|
64
|
-
|
|
63
|
+
@window.push(data, type: type&.to_s) unless data.nil?
|
|
65
64
|
end
|
|
66
65
|
end
|
|
67
66
|
|
|
@@ -91,6 +90,14 @@ module LlmCostTracker
|
|
|
91
90
|
return if snapshot.nil?
|
|
92
91
|
|
|
93
92
|
record_snapshot(snapshot, errored: errored)
|
|
93
|
+
rescue TransactionAbortedError
|
|
94
|
+
raise
|
|
95
|
+
rescue ActiveRecord::RecordNotUnique
|
|
96
|
+
nil
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
raise unless errored
|
|
99
|
+
|
|
100
|
+
Logging.warn("Recording an errored stream raised #{e.class}: #{e.message}; kept the stream's own exception")
|
|
94
101
|
end
|
|
95
102
|
|
|
96
103
|
private
|
|
@@ -102,8 +109,8 @@ module LlmCostTracker
|
|
|
102
109
|
@recording = true
|
|
103
110
|
pricing_mode = Pricing::Mode.normalize(@pricing_mode)
|
|
104
111
|
{
|
|
105
|
-
events: @events
|
|
106
|
-
overflowed: @overflowed
|
|
112
|
+
events: @window.events,
|
|
113
|
+
overflowed: @window.overflowed?,
|
|
107
114
|
explicit_usage: @explicit_usage,
|
|
108
115
|
model: @model,
|
|
109
116
|
latency_ms: @latency_ms,
|
|
@@ -136,10 +143,16 @@ module LlmCostTracker
|
|
|
136
143
|
@mutex.synchronize do
|
|
137
144
|
@finished = save_succeeded
|
|
138
145
|
@recording = false
|
|
146
|
+
release_buffers if save_succeeded
|
|
139
147
|
end
|
|
140
148
|
end
|
|
141
149
|
end
|
|
142
150
|
|
|
151
|
+
def release_buffers
|
|
152
|
+
@window = EventWindow.new
|
|
153
|
+
@request = nil
|
|
154
|
+
end
|
|
155
|
+
|
|
143
156
|
def capture_dimensions
|
|
144
157
|
{
|
|
145
158
|
provider_project_id: @provider_project_id.to_s.strip.presence,
|
|
@@ -155,20 +168,27 @@ module LlmCostTracker
|
|
|
155
168
|
end
|
|
156
169
|
|
|
157
170
|
def build_event(snapshot)
|
|
158
|
-
return
|
|
159
|
-
return build_unknown_usage(snapshot) if snapshot[:overflowed]
|
|
171
|
+
return build_unparsed_event(snapshot) if snapshot[:explicit_usage]
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
if snapshot[:overflowed]
|
|
174
|
+
Logging.warn("#{@provider} stream events exceeded #{SSE::LIMIT_BYTES} bytes; " \
|
|
175
|
+
"recording usage_source=#{Usage::Source::UNKNOWN}.")
|
|
176
|
+
return build_unparsed_event(snapshot)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
request_body = request_body_for(snapshot[:request])
|
|
180
|
+
events = Parsers.all_for_provider(@provider).filter_map do |parser|
|
|
181
|
+
parser.parse_stream(
|
|
182
|
+
response_status: 200, events: snapshot[:events], request_body: request_body, model: snapshot[:model]
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
event = events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
|
|
166
186
|
if event
|
|
167
187
|
model = present_model(event.model) || present_model(snapshot[:model]) || Event::UNKNOWN_MODEL
|
|
168
188
|
return event.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
|
|
169
189
|
end
|
|
170
190
|
|
|
171
|
-
|
|
191
|
+
build_unparsed_event(snapshot)
|
|
172
192
|
end
|
|
173
193
|
|
|
174
194
|
def request_body_for(request)
|
|
@@ -184,78 +204,18 @@ module LlmCostTracker
|
|
|
184
204
|
string unless string == Event::UNKNOWN_MODEL
|
|
185
205
|
end
|
|
186
206
|
|
|
187
|
-
def
|
|
207
|
+
def build_unparsed_event(snapshot)
|
|
208
|
+
explicit_usage = snapshot[:explicit_usage]
|
|
188
209
|
Event.build(
|
|
189
210
|
provider: @provider,
|
|
190
211
|
model: snapshot[:model] || Event::UNKNOWN_MODEL,
|
|
191
|
-
token_usage:
|
|
212
|
+
token_usage: explicit_usage || Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
192
213
|
stream: true,
|
|
193
|
-
usage_source: Usage::Source::MANUAL,
|
|
214
|
+
usage_source: explicit_usage ? Usage::Source::MANUAL : Usage::Source::UNKNOWN,
|
|
194
215
|
pricing_mode: snapshot[:pricing_mode],
|
|
195
216
|
**snapshot.fetch(:capture_dimensions)
|
|
196
217
|
)
|
|
197
218
|
end
|
|
198
|
-
|
|
199
|
-
def build_unknown_usage(snapshot)
|
|
200
|
-
Event.build(
|
|
201
|
-
provider: @provider,
|
|
202
|
-
model: snapshot[:model] || Event::UNKNOWN_MODEL,
|
|
203
|
-
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
204
|
-
stream: true,
|
|
205
|
-
usage_source: Usage::Source::UNKNOWN,
|
|
206
|
-
pricing_mode: snapshot[:pricing_mode],
|
|
207
|
-
**snapshot.fetch(:capture_dimensions)
|
|
208
|
-
)
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
212
|
-
private_constant :IGNORED_PAYLOAD_KEYS
|
|
213
|
-
|
|
214
|
-
HEAVY_STRING_BYTES = 8 * 1024
|
|
215
|
-
private_constant :HEAVY_STRING_BYTES
|
|
216
|
-
|
|
217
|
-
def capture_event(data, type:)
|
|
218
|
-
event = { event: type, data: strip_heavy_payload(data) }
|
|
219
|
-
size = approximate_bytesize(event)
|
|
220
|
-
if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
|
|
221
|
-
@events << event
|
|
222
|
-
@captured_bytes += size
|
|
223
|
-
else
|
|
224
|
-
@overflowed = true
|
|
225
|
-
end
|
|
226
|
-
rescue TypeError, SystemStackError
|
|
227
|
-
@overflowed = true
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def strip_heavy_payload(value)
|
|
231
|
-
case value
|
|
232
|
-
when Hash
|
|
233
|
-
value.each_with_object({}) do |(key, nested), out|
|
|
234
|
-
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
235
|
-
|
|
236
|
-
out[key] = strip_heavy_payload(nested)
|
|
237
|
-
end
|
|
238
|
-
when Array
|
|
239
|
-
value.map { |nested| strip_heavy_payload(nested) }
|
|
240
|
-
when String
|
|
241
|
-
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
242
|
-
else
|
|
243
|
-
value
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def approximate_bytesize(value)
|
|
248
|
-
case value
|
|
249
|
-
when Hash
|
|
250
|
-
value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
|
|
251
|
-
when Array
|
|
252
|
-
value.sum { |nested| approximate_bytesize(nested) + 2 }
|
|
253
|
-
when Numeric, true, false, nil
|
|
254
|
-
8
|
|
255
|
-
else
|
|
256
|
-
value.to_s.bytesize + 2
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
219
|
end
|
|
260
220
|
end
|
|
261
221
|
end
|