llm_cost_tracker 0.14.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 +46 -0
- data/README.md +1 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- 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 +5 -0
- data/app/models/llm_cost_tracker/call.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- 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 +8 -4
- 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/doctor/price_check.rb +1 -0
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -6
- data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
- 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 +6 -1
- 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 +2 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +12 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +12 -11
- 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 +32 -43
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +878 -139
- data/lib/llm_cost_tracker/pricing/registry.rb +12 -1
- 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 +30 -13
- 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/response_parser.rb +5 -1
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +10 -5
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +4 -0
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +3 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +6 -4
- metadata +16 -4
|
@@ -8,10 +8,14 @@ module LlmCostTracker
|
|
|
8
8
|
extend Base
|
|
9
9
|
|
|
10
10
|
minimum_version "1.15.0"
|
|
11
|
+
maximum_version "3.0.0"
|
|
11
12
|
|
|
12
13
|
class << self
|
|
13
14
|
def patch_targets
|
|
14
|
-
[
|
|
15
|
+
[
|
|
16
|
+
patch_target("RubyLLM::Provider", with: ProviderPatch),
|
|
17
|
+
patch_target("RubyLLM::Providers::Gemini::Transcription", with: GeminiTranscriptionPatch, optional: true)
|
|
18
|
+
]
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def record_completion(provider, response, request:, latency_ms:, has_block:)
|
|
@@ -36,25 +40,29 @@ module LlmCostTracker
|
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
def record_transcription(provider, response, request:, latency_ms:)
|
|
43
|
+
model = response_model_id(response) || model_id_from_request(request[:model])
|
|
44
|
+
match = LlmCostTracker::Pricing::Matcher.lookup(provider: provider.slug.to_s, model: model)
|
|
39
45
|
record_usage(
|
|
40
46
|
provider: provider.slug.to_s,
|
|
41
|
-
model:
|
|
47
|
+
model: model,
|
|
42
48
|
response: response,
|
|
43
49
|
latency_ms: latency_ms,
|
|
44
|
-
stream: false
|
|
50
|
+
stream: false,
|
|
51
|
+
audio_input: match&.prices&.key?("audio_input")
|
|
45
52
|
)
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
def record_image(provider, response, request:, latency_ms:)
|
|
49
|
-
|
|
56
|
+
image = response.is_a?(Array) ? response.first : response
|
|
57
|
+
usage = image_usage(image)
|
|
50
58
|
raw_input = usage[:input_tokens].to_i
|
|
51
59
|
raw_output = usage[:output_tokens].to_i
|
|
52
60
|
image_input = image_token_detail(usage, :input)
|
|
53
61
|
image_output = image_token_detail(usage, :output)
|
|
54
62
|
record_passthrough(
|
|
55
63
|
provider: provider.slug.to_s,
|
|
56
|
-
model: response_model_id(
|
|
57
|
-
response:
|
|
64
|
+
model: response_model_id(image) || model_id_from_request(request[:model]),
|
|
65
|
+
response: image,
|
|
58
66
|
latency_ms: latency_ms,
|
|
59
67
|
input_tokens: [raw_input - image_input, 0].max,
|
|
60
68
|
image_input_tokens: image_input,
|
|
@@ -74,6 +82,12 @@ module LlmCostTracker
|
|
|
74
82
|
)
|
|
75
83
|
end
|
|
76
84
|
|
|
85
|
+
def image_usage(image)
|
|
86
|
+
usage = image.try(:usage)
|
|
87
|
+
usage = image.send(:raw_usage) if !usage.is_a?(Hash) && image.respond_to?(:raw_usage, true)
|
|
88
|
+
(usage.is_a?(Hash) ? usage : {}).with_indifferent_access
|
|
89
|
+
end
|
|
90
|
+
|
|
77
91
|
def image_token_detail(usage, direction)
|
|
78
92
|
container_key = direction == :input ? :input_tokens_details : :output_tokens_details
|
|
79
93
|
details = usage[container_key]
|
|
@@ -111,27 +125,29 @@ module LlmCostTracker
|
|
|
111
125
|
end
|
|
112
126
|
end
|
|
113
127
|
|
|
114
|
-
def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil)
|
|
128
|
+
def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil, audio_input: false)
|
|
115
129
|
return unless active?
|
|
116
130
|
|
|
117
131
|
record_safely do
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
counts = token_counts(response)
|
|
133
|
+
input_tokens = counts[:input]
|
|
134
|
+
output_tokens = counts[:output] if output_tokens.nil?
|
|
120
135
|
next if input_tokens.nil? && output_tokens.nil?
|
|
121
136
|
|
|
122
|
-
cache_write_5m, cache_write_1h =
|
|
137
|
+
cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write])
|
|
123
138
|
LlmCostTracker::Tracker.record(
|
|
124
139
|
event: Event.build(
|
|
125
140
|
provider: provider,
|
|
126
141
|
model: model,
|
|
127
142
|
pricing_mode: pricing_mode_for(provider: provider, response: response),
|
|
128
143
|
token_usage: Usage::TokenUsage.build(
|
|
129
|
-
input_tokens: input_tokens.to_i,
|
|
144
|
+
input_tokens: audio_input ? 0 : input_tokens.to_i,
|
|
145
|
+
audio_input_tokens: audio_input ? input_tokens.to_i : 0,
|
|
130
146
|
output_tokens: output_tokens.to_i,
|
|
131
|
-
cache_read_input_tokens:
|
|
147
|
+
cache_read_input_tokens: counts[:cache_read].to_i,
|
|
132
148
|
cache_write_input_tokens: cache_write_5m,
|
|
133
149
|
cache_write_extended_input_tokens: cache_write_1h,
|
|
134
|
-
hidden_output_tokens:
|
|
150
|
+
hidden_output_tokens: counts[:thinking].to_i
|
|
135
151
|
),
|
|
136
152
|
stream: stream,
|
|
137
153
|
usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
|
|
@@ -142,11 +158,22 @@ module LlmCostTracker
|
|
|
142
158
|
end
|
|
143
159
|
end
|
|
144
160
|
|
|
145
|
-
def
|
|
146
|
-
|
|
161
|
+
def token_counts(response)
|
|
162
|
+
tokens = response.try(:tokens)
|
|
163
|
+
return { input: response.try(:input_tokens), output: response.try(:output_tokens) } unless tokens
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
input: tokens.input,
|
|
167
|
+
output: tokens.output,
|
|
168
|
+
cache_read: tokens.cache_read,
|
|
169
|
+
cache_write: tokens.cache_write,
|
|
170
|
+
thinking: tokens.thinking
|
|
171
|
+
}
|
|
172
|
+
end
|
|
147
173
|
|
|
148
|
-
|
|
149
|
-
|
|
174
|
+
def cache_write_split(provider, response, cache_write)
|
|
175
|
+
cache = raw_body(response).dig("usage", "cache_creation") if provider == "anthropic"
|
|
176
|
+
return [cache_write.to_i, 0] unless cache.is_a?(Hash)
|
|
150
177
|
|
|
151
178
|
[cache["ephemeral_5m_input_tokens"].to_i, cache["ephemeral_1h_input_tokens"].to_i]
|
|
152
179
|
end
|
|
@@ -164,7 +191,8 @@ module LlmCostTracker
|
|
|
164
191
|
end
|
|
165
192
|
|
|
166
193
|
def raw_body(response)
|
|
167
|
-
|
|
194
|
+
raw = response.try(:raw)
|
|
195
|
+
body = raw.respond_to?(:body) ? raw.body : raw
|
|
168
196
|
body.is_a?(Hash) ? body : {}
|
|
169
197
|
end
|
|
170
198
|
|
|
@@ -217,6 +245,13 @@ module LlmCostTracker
|
|
|
217
245
|
LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
|
|
218
246
|
end
|
|
219
247
|
end
|
|
248
|
+
|
|
249
|
+
module GeminiTranscriptionPatch
|
|
250
|
+
def transcribe(*args, **kwargs)
|
|
251
|
+
seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription)
|
|
252
|
+
LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
220
255
|
end
|
|
221
256
|
end
|
|
222
257
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Isolation
|
|
8
|
+
class << self
|
|
9
|
+
def guard(model = LlmCostTracker::Call, &)
|
|
10
|
+
connection = model.connection
|
|
11
|
+
nested = connection.transaction_open?
|
|
12
|
+
return yield unless nested
|
|
13
|
+
|
|
14
|
+
model.transaction(requires_new: true, &)
|
|
15
|
+
rescue ActiveRecord::TransactionRollbackError => e
|
|
16
|
+
raise unless nested && connection.savepoint_errors_invalidate_transactions?
|
|
17
|
+
|
|
18
|
+
raise TransactionAbortedError, e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def after_commit(&)
|
|
22
|
+
current = LlmCostTracker::Call.connection.current_transaction
|
|
23
|
+
return yield unless current.open? && current.joinable? && current.respond_to?(:after_commit)
|
|
24
|
+
|
|
25
|
+
current.after_commit(&)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal/util"
|
|
4
4
|
|
|
5
|
+
require_relative "../isolation"
|
|
5
6
|
require_relative "../period"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
@@ -22,7 +23,7 @@ module LlmCostTracker
|
|
|
22
23
|
|
|
23
24
|
values = periods.to_h { |period| [period, BigDecimal("0")] }
|
|
24
25
|
period_by_name = periods.to_h { |period| [period.to_s, period] }
|
|
25
|
-
LlmCostTracker::Call.find_by_sql(union_sql).each do |row|
|
|
26
|
+
Isolation.guard { LlmCostTracker::Call.find_by_sql(union_sql) }.each do |row|
|
|
26
27
|
values[period_by_name.fetch(row.period_key)] = row.total_cost.to_d
|
|
27
28
|
end
|
|
28
29
|
values
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal/util"
|
|
4
4
|
|
|
5
|
+
require_relative "isolation"
|
|
5
6
|
require_relative "period"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
@@ -33,14 +34,21 @@ module LlmCostTracker
|
|
|
33
34
|
:REBUILD_INSERT_SLICE
|
|
34
35
|
|
|
35
36
|
def increment_safely!(events)
|
|
37
|
+
return unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
38
|
+
|
|
39
|
+
Isolation.after_commit { increment_with_retries(events) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def increment_with_retries(events)
|
|
43
|
+
retryable = !LlmCostTracker::Call.connection.transaction_open?
|
|
36
44
|
attempt = 0
|
|
37
45
|
begin
|
|
38
46
|
attempt += 1
|
|
39
|
-
increment!(events)
|
|
47
|
+
Isolation.guard { increment!(events) }
|
|
48
|
+
rescue LlmCostTracker::Error
|
|
49
|
+
raise
|
|
40
50
|
rescue StandardError => e
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
|
|
51
|
+
if retryable && attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
|
|
44
52
|
sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
|
|
45
53
|
retry
|
|
46
54
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ledger
|
|
5
|
+
module Storable
|
|
6
|
+
def self.clean(value)
|
|
7
|
+
case value
|
|
8
|
+
when Hash then value.to_h { |key, nested| [clean(key), clean(nested)] }
|
|
9
|
+
when Array then value.map { |nested| clean(nested) }
|
|
10
|
+
when String then value.dup.force_encoding(Encoding::UTF_8).scrub.delete("\u0000")
|
|
11
|
+
else value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "../pricing"
|
|
|
6
6
|
require_relative "rollups"
|
|
7
7
|
require_relative "../budget/per_tag"
|
|
8
8
|
require_relative "tags/encoding"
|
|
9
|
+
require_relative "storable"
|
|
9
10
|
|
|
10
11
|
module LlmCostTracker
|
|
11
12
|
module Ledger
|
|
@@ -23,11 +24,13 @@ module LlmCostTracker
|
|
|
23
24
|
events = Array(events)
|
|
24
25
|
return if events.empty?
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
Isolation.guard do
|
|
28
|
+
LlmCostTracker::Call.transaction do
|
|
29
|
+
rows = events.map { |event| attributes_for(event) }
|
|
30
|
+
call_ids = insert_calls_returning_ids(rows, events)
|
|
31
|
+
insert_line_items(events, call_ids)
|
|
32
|
+
insert_call_tags(events, call_ids)
|
|
33
|
+
end
|
|
31
34
|
end
|
|
32
35
|
end
|
|
33
36
|
|
|
@@ -62,9 +65,7 @@ module LlmCostTracker
|
|
|
62
65
|
pricing_snapshot: event.pricing_snapshot
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
attributes
|
|
66
|
-
.merge(event.token_usage.to_h)
|
|
67
|
-
.merge(total_cost: event.cost&.total)
|
|
68
|
+
Storable.clean(attributes.merge(event.token_usage.to_h).merge(total_cost: event.cost&.total))
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def call_ids_for(events)
|
|
@@ -90,7 +91,7 @@ module LlmCostTracker
|
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
def line_item_attributes(call_id:, line_item:, position:)
|
|
93
|
-
|
|
94
|
+
Storable.clean(
|
|
94
95
|
llm_cost_tracker_call_id: call_id,
|
|
95
96
|
position: position,
|
|
96
97
|
kind: line_item.kind,
|
|
@@ -112,7 +113,7 @@ module LlmCostTracker
|
|
|
112
113
|
provider_item_id: line_item.provider_item_id,
|
|
113
114
|
details: stored_details(line_item.details),
|
|
114
115
|
created_at: Time.now.utc
|
|
115
|
-
|
|
116
|
+
)
|
|
116
117
|
end
|
|
117
118
|
|
|
118
119
|
def insert_call_tags(events, call_ids)
|
|
@@ -121,7 +122,7 @@ module LlmCostTracker
|
|
|
121
122
|
{
|
|
122
123
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
123
124
|
key: key.to_s,
|
|
124
|
-
value: Tags::Encoding.encode(value)
|
|
125
|
+
value: Tags::Encoding.encode(Storable.clean(value))
|
|
125
126
|
}.merge(budget_columns_for(event))
|
|
126
127
|
end
|
|
127
128
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "redaction"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Logging
|
|
5
7
|
class << self
|
|
@@ -9,7 +11,7 @@ module LlmCostTracker
|
|
|
9
11
|
|
|
10
12
|
private
|
|
11
13
|
|
|
12
|
-
def prefixed(message) = "[#{LlmCostTracker.name}] #{message}"
|
|
14
|
+
def prefixed(message) = "[#{LlmCostTracker.name}] #{Redaction.text(message)}"
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
end
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
-
require "stringio"
|
|
6
5
|
require "uri"
|
|
7
6
|
|
|
8
7
|
require_relative "../capture/sse"
|
|
8
|
+
require_relative "../capture/stream_tap"
|
|
9
9
|
require_relative "../timing"
|
|
10
10
|
|
|
11
11
|
module LlmCostTracker
|
|
@@ -27,16 +27,17 @@ module LlmCostTracker
|
|
|
27
27
|
if streaming
|
|
28
28
|
request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
|
|
29
29
|
end
|
|
30
|
-
stream_buffer = install_stream_tap(request_env) if streaming
|
|
30
|
+
stream_buffer = install_stream_tap(request_env, parser) if streaming
|
|
31
31
|
|
|
32
32
|
if parser
|
|
33
|
+
context_tags, metadata = tag_snapshot(request_env)
|
|
33
34
|
Budget.enforce!(
|
|
34
35
|
provider: parser.provider_for(request_url),
|
|
35
36
|
model: parser.model_for(request_url, request_parsed),
|
|
36
|
-
request: request_parsed
|
|
37
|
+
request: request_parsed,
|
|
38
|
+
tags: Tracker.build_tags(context_tags: context_tags, metadata: metadata)
|
|
37
39
|
)
|
|
38
40
|
end
|
|
39
|
-
context_tags, metadata = tag_snapshot(request_env) if parser
|
|
40
41
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
41
42
|
|
|
42
43
|
invoke_app_with_capture(
|
|
@@ -95,7 +96,7 @@ module LlmCostTracker
|
|
|
95
96
|
|
|
96
97
|
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
97
98
|
return nil unless LlmCostTracker.configuration.capture.request_stream_usage
|
|
98
|
-
return nil unless parser
|
|
99
|
+
return nil unless parser.auto_enable_stream_usage?(request_url, request_parsed)
|
|
99
100
|
|
|
100
101
|
stream_options = request_parsed["stream_options"]
|
|
101
102
|
return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
|
|
@@ -116,21 +117,22 @@ module LlmCostTracker
|
|
|
116
117
|
request = parser.safe_json_parse(request_body)
|
|
117
118
|
event = Event.build(
|
|
118
119
|
provider: parser.provider_for(request_url),
|
|
119
|
-
model: request
|
|
120
|
+
model: parser.model_for(request_url, request) || Event::UNKNOWN_MODEL,
|
|
120
121
|
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
121
122
|
stream: true,
|
|
122
123
|
usage_source: Usage::Source::UNKNOWN
|
|
123
124
|
)
|
|
124
|
-
merged_metadata = (metadata || {}).merge(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
125
|
+
merged_metadata = (metadata || {}).merge(stream_interrupted: true, stream_interrupted_error: error.class.name)
|
|
126
|
+
status = error.try(:response_status)
|
|
127
|
+
merged_metadata[:stream_interrupted_status] = status if status
|
|
128
128
|
Tracker.record(
|
|
129
129
|
event: event,
|
|
130
130
|
latency_ms: latency_ms,
|
|
131
131
|
metadata: merged_metadata,
|
|
132
132
|
context_tags: context_tags
|
|
133
133
|
)
|
|
134
|
+
rescue LlmCostTracker::TransactionAbortedError
|
|
135
|
+
raise
|
|
134
136
|
rescue StandardError => e
|
|
135
137
|
Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
|
|
136
138
|
end
|
|
@@ -171,7 +173,7 @@ module LlmCostTracker
|
|
|
171
173
|
metadata: metadata,
|
|
172
174
|
context_tags: context_tags
|
|
173
175
|
)
|
|
174
|
-
rescue LlmCostTracker::
|
|
176
|
+
rescue *LlmCostTracker::CALLER_ERRORS
|
|
175
177
|
raise
|
|
176
178
|
rescue StandardError => e
|
|
177
179
|
Logging.warn("Error processing response: #{e.class}: #{e.message}")
|
|
@@ -198,32 +200,28 @@ module LlmCostTracker
|
|
|
198
200
|
end
|
|
199
201
|
|
|
200
202
|
def parse_stream(parser:, request_url:, request_body:, response_env:, stream_buffer:)
|
|
201
|
-
overflowed = stream_buffer&.dig(:overflowed) == true
|
|
202
|
-
Logging.warn(capture_warning(request_url, stream_buffer)) if overflowed
|
|
203
|
-
|
|
204
|
-
body = stream_buffer&.dig(:buffer)&.string
|
|
205
|
-
body = read_body(response_env.body) if body.blank?
|
|
206
|
-
|
|
207
|
-
if body.blank?
|
|
208
|
-
Logging.warn(capture_warning(request_url, stream_buffer)) unless overflowed
|
|
209
|
-
return parser.parse_stream(
|
|
210
|
-
request_url: request_url,
|
|
211
|
-
request_body: request_body,
|
|
212
|
-
response_status: response_env.status,
|
|
213
|
-
response_headers: response_env.response_headers
|
|
214
|
-
)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
events = overflowed ? [] : Capture::SSE.parse(body)
|
|
218
203
|
parser.parse_stream(
|
|
219
204
|
request_url: request_url,
|
|
220
205
|
request_body: request_body,
|
|
221
206
|
response_status: response_env.status,
|
|
222
|
-
events:
|
|
207
|
+
events: stream_events(request_url, response_env, stream_buffer),
|
|
223
208
|
response_headers: response_env.response_headers
|
|
224
209
|
)
|
|
225
210
|
end
|
|
226
211
|
|
|
212
|
+
def stream_events(request_url, response_env, stream_buffer)
|
|
213
|
+
if stream_buffer&.received?
|
|
214
|
+
events = stream_buffer.events
|
|
215
|
+
return events unless stream_buffer.overflowed? || stream_buffer.failed?
|
|
216
|
+
else
|
|
217
|
+
body = read_body(response_env.body)
|
|
218
|
+
return Capture::SSE.parse(body) if body.present?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
Logging.warn(capture_warning(request_url, stream_buffer))
|
|
222
|
+
[]
|
|
223
|
+
end
|
|
224
|
+
|
|
227
225
|
def forward_on_data_chunk(callable, chunk, size, env)
|
|
228
226
|
arity = callable.arity
|
|
229
227
|
return callable.call(chunk, size, env) if arity.negative?
|
|
@@ -235,28 +233,19 @@ module LlmCostTracker
|
|
|
235
233
|
end
|
|
236
234
|
end
|
|
237
235
|
|
|
238
|
-
def install_stream_tap(request_env)
|
|
236
|
+
def install_stream_tap(request_env, parser)
|
|
239
237
|
request = request_env.request
|
|
240
238
|
return nil unless request
|
|
241
239
|
|
|
242
240
|
original = request.on_data
|
|
243
241
|
return nil unless original
|
|
244
242
|
|
|
245
|
-
|
|
243
|
+
tap = Capture::StreamTap.new(notable: parser.method(:retain_stream_event?))
|
|
246
244
|
request.on_data = proc do |chunk, size, env|
|
|
247
|
-
|
|
248
|
-
remaining = Capture::SSE::LIMIT_BYTES - state[:bytes]
|
|
249
|
-
if chunk.bytesize <= remaining
|
|
250
|
-
state[:buffer] << chunk
|
|
251
|
-
state[:bytes] += chunk.bytesize
|
|
252
|
-
else
|
|
253
|
-
state[:buffer] << chunk.byteslice(0, remaining) if remaining.positive?
|
|
254
|
-
state[:bytes] += [remaining, 0].max
|
|
255
|
-
state[:overflowed] = true
|
|
256
|
-
end
|
|
245
|
+
tap << chunk
|
|
257
246
|
forward_on_data_chunk(original, chunk, size, env)
|
|
258
247
|
end
|
|
259
|
-
|
|
248
|
+
tap
|
|
260
249
|
rescue StandardError => e
|
|
261
250
|
Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
|
|
262
251
|
nil
|
|
@@ -295,7 +284,7 @@ module LlmCostTracker
|
|
|
295
284
|
suffix = "recording usage_source=#{Usage::Source::UNKNOWN}. " \
|
|
296
285
|
"Use LlmCostTracker.track_stream for manual capture."
|
|
297
286
|
label = request_url_label(request_url)
|
|
298
|
-
return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.
|
|
287
|
+
return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.overflowed?
|
|
299
288
|
|
|
300
289
|
"Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
|
|
301
290
|
end
|