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,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
-
require "json"
|
|
5
|
-
require "uri"
|
|
6
4
|
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "../capture/stream_tap"
|
|
9
|
-
require_relative "../timing"
|
|
5
|
+
require_relative "faraday/exchange"
|
|
10
6
|
|
|
11
7
|
module LlmCostTracker
|
|
12
8
|
module Middleware
|
|
13
9
|
class Faraday < ::Faraday::Middleware
|
|
14
|
-
MULTIPART_MODEL = /name="model"\r\n(?:[^\r\n]+\r\n)*\r\n([^\r\n]+)\r\n/
|
|
15
|
-
|
|
16
10
|
def initialize(app, **options)
|
|
17
11
|
super(app)
|
|
18
12
|
@tags = options.fetch(:tags, {})
|
|
@@ -21,300 +15,7 @@ module LlmCostTracker
|
|
|
21
15
|
def call(request_env)
|
|
22
16
|
return @app.call(request_env) unless LlmCostTracker.configuration.enabled
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
parser = Parsers.find_for(request_url)
|
|
26
|
-
request_body = read_body(request_env.body) || multipart_model_body(request_env, parser)
|
|
27
|
-
request_parsed = parser&.safe_json_parse(request_body)
|
|
28
|
-
streaming = parser&.streaming_request?(request_url, request_parsed)
|
|
29
|
-
if streaming
|
|
30
|
-
request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
|
|
31
|
-
end
|
|
32
|
-
stream_buffer = install_stream_tap(request_env, parser) if streaming
|
|
33
|
-
|
|
34
|
-
context_tags, metadata = tag_snapshot(request_env) if parser
|
|
35
|
-
if parser && request_env.method == :post
|
|
36
|
-
Budget.enforce!(
|
|
37
|
-
provider: parser.provider_for(request_url),
|
|
38
|
-
model: parser.model_for(request_url, request_parsed),
|
|
39
|
-
request: request_parsed,
|
|
40
|
-
tags: Tracker.build_tags(context_tags: context_tags, metadata: metadata)
|
|
41
|
-
)
|
|
42
|
-
end
|
|
43
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
44
|
-
|
|
45
|
-
invoke_app_with_capture(
|
|
46
|
-
request_env: request_env,
|
|
47
|
-
parser: parser,
|
|
48
|
-
request_url: request_url,
|
|
49
|
-
request_body: request_body,
|
|
50
|
-
streaming: streaming,
|
|
51
|
-
stream_buffer: stream_buffer,
|
|
52
|
-
context_tags: context_tags,
|
|
53
|
-
metadata: metadata,
|
|
54
|
-
started_at: started_at
|
|
55
|
-
)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def invoke_app_with_capture(request_env:,
|
|
61
|
-
parser:,
|
|
62
|
-
request_url:,
|
|
63
|
-
request_body:,
|
|
64
|
-
streaming:,
|
|
65
|
-
stream_buffer:,
|
|
66
|
-
context_tags:,
|
|
67
|
-
metadata:,
|
|
68
|
-
started_at:)
|
|
69
|
-
response_received = false
|
|
70
|
-
@app.call(request_env).on_complete do |response_env|
|
|
71
|
-
response_received = true
|
|
72
|
-
process(
|
|
73
|
-
parser: parser,
|
|
74
|
-
request_url: request_url,
|
|
75
|
-
request_body: request_body,
|
|
76
|
-
response_env: response_env,
|
|
77
|
-
latency_ms: (LlmCostTracker::Timing.elapsed_ms(started_at) if request_env.method == :post),
|
|
78
|
-
streaming: streaming,
|
|
79
|
-
stream_buffer: stream_buffer,
|
|
80
|
-
context_tags: context_tags,
|
|
81
|
-
metadata: metadata
|
|
82
|
-
)
|
|
83
|
-
end
|
|
84
|
-
rescue StandardError => e
|
|
85
|
-
if streaming && parser && !response_received
|
|
86
|
-
process_interrupted_stream(
|
|
87
|
-
parser: parser,
|
|
88
|
-
request_url: request_url,
|
|
89
|
-
request_body: request_body,
|
|
90
|
-
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
91
|
-
context_tags: context_tags,
|
|
92
|
-
metadata: metadata,
|
|
93
|
-
error: e
|
|
94
|
-
)
|
|
95
|
-
end
|
|
96
|
-
raise
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
100
|
-
return nil unless LlmCostTracker.configuration.capture.request_stream_usage
|
|
101
|
-
return nil unless parser.auto_enable_stream_usage?(request_url, request_parsed)
|
|
102
|
-
|
|
103
|
-
stream_options = request_parsed["stream_options"]
|
|
104
|
-
return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
|
|
105
|
-
|
|
106
|
-
request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
|
|
107
|
-
new_body = request_parsed.to_json
|
|
108
|
-
request_env.body = new_body
|
|
109
|
-
new_body
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def process_interrupted_stream(parser:,
|
|
113
|
-
request_url:,
|
|
114
|
-
request_body:,
|
|
115
|
-
latency_ms:,
|
|
116
|
-
context_tags:,
|
|
117
|
-
metadata:,
|
|
118
|
-
error:)
|
|
119
|
-
request = parser.safe_json_parse(request_body)
|
|
120
|
-
event = Event.build(
|
|
121
|
-
provider: parser.provider_for(request_url),
|
|
122
|
-
model: parser.model_for(request_url, request) || Event::UNKNOWN_MODEL,
|
|
123
|
-
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
124
|
-
stream: true,
|
|
125
|
-
usage_source: Usage::Source::UNKNOWN
|
|
126
|
-
)
|
|
127
|
-
merged_metadata = (metadata || {}).merge(stream_interrupted: true, stream_interrupted_error: error.class.name)
|
|
128
|
-
status = error.try(:response_status)
|
|
129
|
-
merged_metadata[:stream_interrupted_status] = status if status
|
|
130
|
-
Tracker.record(
|
|
131
|
-
event: event,
|
|
132
|
-
latency_ms: latency_ms,
|
|
133
|
-
metadata: merged_metadata,
|
|
134
|
-
context_tags: context_tags
|
|
135
|
-
)
|
|
136
|
-
rescue LlmCostTracker::TransactionAbortedError
|
|
137
|
-
raise
|
|
138
|
-
rescue StandardError => e
|
|
139
|
-
Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def process(parser:,
|
|
143
|
-
request_url:,
|
|
144
|
-
request_body:,
|
|
145
|
-
response_env:,
|
|
146
|
-
latency_ms:,
|
|
147
|
-
streaming:,
|
|
148
|
-
stream_buffer:,
|
|
149
|
-
context_tags:,
|
|
150
|
-
metadata:)
|
|
151
|
-
return unless parser
|
|
152
|
-
|
|
153
|
-
parsed =
|
|
154
|
-
if streaming
|
|
155
|
-
parse_stream(
|
|
156
|
-
parser: parser,
|
|
157
|
-
request_url: request_url,
|
|
158
|
-
request_body: request_body,
|
|
159
|
-
response_env: response_env,
|
|
160
|
-
stream_buffer: stream_buffer
|
|
161
|
-
)
|
|
162
|
-
else
|
|
163
|
-
parse_response(
|
|
164
|
-
parser: parser,
|
|
165
|
-
request_url: request_url,
|
|
166
|
-
request_body: request_body,
|
|
167
|
-
response_env: response_env
|
|
168
|
-
)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
Tracker.record(event: parsed, latency_ms: latency_ms, metadata: metadata, context_tags: context_tags) if parsed
|
|
172
|
-
rescue *LlmCostTracker::CALLER_ERRORS
|
|
173
|
-
raise
|
|
174
|
-
rescue ActiveRecord::RecordNotUnique
|
|
175
|
-
nil
|
|
176
|
-
rescue StandardError => e
|
|
177
|
-
Logging.warn("Error processing response: #{e.class}: #{e.message}")
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def parse_response(parser:, request_url:, request_body:, response_env:)
|
|
181
|
-
response_body = read_body(response_env.body)
|
|
182
|
-
unless response_body
|
|
183
|
-
Logging.warn(
|
|
184
|
-
"Unable to read response body for #{request_url_label(request_url)}; " \
|
|
185
|
-
"known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
|
|
186
|
-
"for custom clients."
|
|
187
|
-
)
|
|
188
|
-
return nil
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
parser.parse(
|
|
192
|
-
request_url: request_url,
|
|
193
|
-
request_body: request_body,
|
|
194
|
-
response_status: response_env.status,
|
|
195
|
-
response_body: response_body,
|
|
196
|
-
response_headers: response_env.response_headers
|
|
197
|
-
)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def parse_stream(parser:, request_url:, request_body:, response_env:, stream_buffer:)
|
|
201
|
-
parser.parse_stream(
|
|
202
|
-
request_url: request_url,
|
|
203
|
-
request_body: request_body,
|
|
204
|
-
response_status: response_env.status,
|
|
205
|
-
events: stream_events(request_url, response_env, stream_buffer),
|
|
206
|
-
response_headers: response_env.response_headers
|
|
207
|
-
)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def stream_events(request_url, response_env, stream_buffer)
|
|
211
|
-
if stream_buffer&.received?
|
|
212
|
-
events = stream_buffer.events
|
|
213
|
-
return events unless stream_buffer.overflowed? || stream_buffer.failed?
|
|
214
|
-
else
|
|
215
|
-
body = read_body(response_env.body)
|
|
216
|
-
return Capture::SSE.parse(body) if body.present?
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
Logging.warn(capture_warning(request_url, stream_buffer))
|
|
220
|
-
[]
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def forward_on_data_chunk(callable, chunk, size, env)
|
|
224
|
-
arity = callable.arity
|
|
225
|
-
return callable.call(chunk, size, env) if arity.negative?
|
|
226
|
-
|
|
227
|
-
case arity
|
|
228
|
-
when 0, 1 then callable.call(chunk)
|
|
229
|
-
when 2 then callable.call(chunk, size)
|
|
230
|
-
else callable.call(chunk, size, env)
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def install_stream_tap(request_env, parser)
|
|
235
|
-
request = request_env.request
|
|
236
|
-
return nil unless request
|
|
237
|
-
|
|
238
|
-
original = request.on_data
|
|
239
|
-
return nil unless original
|
|
240
|
-
|
|
241
|
-
tap = Capture::StreamTap.new(notable: parser.method(:retain_stream_event?))
|
|
242
|
-
request.on_data = proc do |chunk, size, env|
|
|
243
|
-
tap << chunk
|
|
244
|
-
forward_on_data_chunk(original, chunk, size, env)
|
|
245
|
-
end
|
|
246
|
-
tap
|
|
247
|
-
rescue StandardError => e
|
|
248
|
-
Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
|
|
249
|
-
nil
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def read_body(body)
|
|
253
|
-
case body
|
|
254
|
-
when String then body
|
|
255
|
-
when nil then ""
|
|
256
|
-
when Hash, Array then body.to_json
|
|
257
|
-
else
|
|
258
|
-
body.try(:to_str)
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def multipart_model_body(request_env, parser)
|
|
263
|
-
body = request_env.body
|
|
264
|
-
multipart = request_env.request_headers["Content-Type"].to_s.start_with?("multipart/form-data")
|
|
265
|
-
return nil unless parser && multipart && body.respond_to?(:read) && body.respond_to?(:rewind)
|
|
266
|
-
|
|
267
|
-
model = multipart_model(body)
|
|
268
|
-
model && { "model" => model }.to_json
|
|
269
|
-
rescue StandardError => e
|
|
270
|
-
Logging.warn("Unable to read the model from a multipart request: #{e.class}: #{e.message}")
|
|
271
|
-
nil
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def multipart_model(body)
|
|
275
|
-
window = String.new(encoding: Encoding::BINARY)
|
|
276
|
-
while (chunk = body.read(65_536))
|
|
277
|
-
window << chunk.b
|
|
278
|
-
model = window[MULTIPART_MODEL, 1]
|
|
279
|
-
return model.force_encoding(Encoding::UTF_8) if model
|
|
280
|
-
|
|
281
|
-
window = window.byteslice([window.bytesize - 512, 0].max..)
|
|
282
|
-
end
|
|
283
|
-
ensure
|
|
284
|
-
body.rewind
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def resolved_tags(request_env)
|
|
288
|
-
return @tags.to_h unless @tags.respond_to?(:call)
|
|
289
|
-
|
|
290
|
-
(@tags.arity.zero? ? @tags.call : @tags.call(request_env)).to_h
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def tag_snapshot(request_env)
|
|
294
|
-
[LlmCostTracker::Tags::Context.tags, resolved_tags(request_env)]
|
|
295
|
-
rescue StandardError => e
|
|
296
|
-
Logging.warn("Error resolving request tags: #{e.class}: #{e.message}")
|
|
297
|
-
[{}, {}]
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
def capture_warning(request_url, stream_buffer)
|
|
301
|
-
suffix = "recording usage_source=#{Usage::Source::UNKNOWN}. " \
|
|
302
|
-
"Use LlmCostTracker.track_stream for manual capture."
|
|
303
|
-
label = request_url_label(request_url)
|
|
304
|
-
return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.overflowed?
|
|
305
|
-
|
|
306
|
-
"Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def request_url_label(value)
|
|
310
|
-
uri = URI.parse(value.to_s)
|
|
311
|
-
uri.query = nil
|
|
312
|
-
uri.fragment = nil
|
|
313
|
-
uri.user = nil
|
|
314
|
-
uri.password = nil
|
|
315
|
-
uri.to_s
|
|
316
|
-
rescue URI::InvalidURIError
|
|
317
|
-
value.to_s.split("?", 2).first
|
|
18
|
+
Exchange.new(request_env, tags: @tags).call(@app)
|
|
318
19
|
end
|
|
319
20
|
end
|
|
320
21
|
end
|
|
@@ -14,6 +14,13 @@ module LlmCostTracker
|
|
|
14
14
|
instances.find { |parser| parser.class.match?(url) }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def self.batch_submission?(uri)
|
|
18
|
+
host = uri.host.to_s.downcase
|
|
19
|
+
uri.path.to_s.match?(%r{/(?:v1|openai)/batches\z}) &&
|
|
20
|
+
(Providers::Openai::Hosts::API_HOSTS.include?(host) || Providers::Azure::Hosts.openai?(host) ||
|
|
21
|
+
LlmCostTracker.configuration.capture.openai_compatible_providers.key?(host))
|
|
22
|
+
end
|
|
23
|
+
|
|
17
24
|
def self.find_for_provider(provider)
|
|
18
25
|
all_for_provider(provider).first
|
|
19
26
|
end
|
|
@@ -75,11 +82,16 @@ module LlmCostTracker
|
|
|
75
82
|
false
|
|
76
83
|
end
|
|
77
84
|
|
|
85
|
+
def trim_stream_event(data)
|
|
86
|
+
data
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
def auto_enable_stream_usage?(_request_url, _request_parsed)
|
|
79
90
|
false
|
|
80
91
|
end
|
|
81
92
|
|
|
82
93
|
def safe_json_parse(body)
|
|
94
|
+
return body if body.is_a?(Hash)
|
|
83
95
|
return {} if body.blank?
|
|
84
96
|
|
|
85
97
|
parsed = JSON.parse(body)
|