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