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
|
@@ -11,6 +11,15 @@ require_relative "storable"
|
|
|
11
11
|
module LlmCostTracker
|
|
12
12
|
module Ledger
|
|
13
13
|
module Store
|
|
14
|
+
CALL_FIELDS = %i[
|
|
15
|
+
event_id provider model tracked_at pricing_mode latency_ms stream usage_source
|
|
16
|
+
provider_response_id provider_project_id provider_api_key_id provider_workspace_id
|
|
17
|
+
].freeze
|
|
18
|
+
LINE_ITEM_FIELDS = %i[
|
|
19
|
+
kind direction modality cache_state quantity unit rate_amount rate_quantity cost currency cost_status
|
|
20
|
+
pricing_basis price_key price_source price_source_version provider_field provider_item_id
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
14
23
|
class << self
|
|
15
24
|
def insert(events)
|
|
16
25
|
events = Array(events)
|
|
@@ -37,7 +46,7 @@ module LlmCostTracker
|
|
|
37
46
|
private
|
|
38
47
|
|
|
39
48
|
def insert_calls_returning_ids(rows, insertable)
|
|
40
|
-
if LlmCostTracker::Call.
|
|
49
|
+
if LlmCostTracker::Call.lease_connection.supports_insert_returning?
|
|
41
50
|
result = LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: %i[id event_id])
|
|
42
51
|
result.rows.to_h { |id, event_id| [event_id, id] }
|
|
43
52
|
else
|
|
@@ -47,25 +56,14 @@ module LlmCostTracker
|
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
def attributes_for(event)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
provider: event.provider,
|
|
53
|
-
model: event.model,
|
|
54
|
-
tracked_at: event.tracked_at,
|
|
55
|
-
pricing_mode: event.pricing_mode,
|
|
56
|
-
latency_ms: event.latency_ms,
|
|
57
|
-
stream: event.stream,
|
|
58
|
-
usage_source: event.usage_source,
|
|
59
|
-
provider_response_id: event.provider_response_id,
|
|
60
|
-
provider_project_id: event.provider_project_id,
|
|
61
|
-
provider_api_key_id: event.provider_api_key_id,
|
|
62
|
-
provider_workspace_id: event.provider_workspace_id,
|
|
59
|
+
Storable.clean(
|
|
60
|
+
**CALL_FIELDS.to_h { |field| [field, event.public_send(field)] },
|
|
63
61
|
batch: event.batch?,
|
|
64
62
|
cost_status: event.cost_status,
|
|
65
|
-
pricing_snapshot: event.pricing_snapshot
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
pricing_snapshot: event.pricing_snapshot,
|
|
64
|
+
**event.token_usage.to_h,
|
|
65
|
+
total_cost: event.cost&.total
|
|
66
|
+
)
|
|
69
67
|
end
|
|
70
68
|
|
|
71
69
|
def call_ids_for(events)
|
|
@@ -94,23 +92,7 @@ module LlmCostTracker
|
|
|
94
92
|
Storable.clean(
|
|
95
93
|
llm_cost_tracker_call_id: call_id,
|
|
96
94
|
position: position,
|
|
97
|
-
|
|
98
|
-
direction: line_item.direction,
|
|
99
|
-
modality: line_item.modality,
|
|
100
|
-
cache_state: line_item.cache_state,
|
|
101
|
-
quantity: line_item.quantity,
|
|
102
|
-
unit: line_item.unit,
|
|
103
|
-
rate_amount: line_item.rate_amount,
|
|
104
|
-
rate_quantity: line_item.rate_quantity,
|
|
105
|
-
cost: line_item.cost,
|
|
106
|
-
currency: line_item.currency,
|
|
107
|
-
cost_status: line_item.cost_status,
|
|
108
|
-
pricing_basis: line_item.pricing_basis,
|
|
109
|
-
price_key: line_item.price_key,
|
|
110
|
-
price_source: line_item.price_source,
|
|
111
|
-
price_source_version: line_item.price_source_version,
|
|
112
|
-
provider_field: line_item.provider_field,
|
|
113
|
-
provider_item_id: line_item.provider_item_id,
|
|
95
|
+
**LINE_ITEM_FIELDS.to_h { |field| [field, line_item.public_send(field)] },
|
|
114
96
|
details: stored_details(line_item.details),
|
|
115
97
|
created_at: Time.now.utc
|
|
116
98
|
)
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
class << self
|
|
12
12
|
def join_relation(scope, key)
|
|
13
13
|
validated_key = LlmCostTracker::Tags::Key.validate!(key)
|
|
14
|
-
connection = scope.
|
|
14
|
+
connection = scope.lease_connection
|
|
15
15
|
join = "LEFT OUTER JOIN #{call_tag_table} ON " \
|
|
16
16
|
"#{call_tag_table}.llm_cost_tracker_call_id = #{scope.quoted_table_name}.id AND " \
|
|
17
17
|
"#{call_tag_table}.#{connection.quote_column_name('key')} = #{connection.quote(validated_key)}"
|
|
@@ -19,7 +19,7 @@ module LlmCostTracker
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def value_arel
|
|
22
|
-
Arel.sql(raw_value_sql(LlmCostTracker::CallTag.
|
|
22
|
+
Arel.sql(raw_value_sql(LlmCostTracker::CallTag.lease_connection))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def label_sql(connection)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Middleware
|
|
8
|
+
class Faraday < ::Faraday::Middleware
|
|
9
|
+
module Body
|
|
10
|
+
MULTIPART_BOUNDARY = %r{\Amultipart/form-data\b.*?\bboundary="?([^";\s]+)"?}i
|
|
11
|
+
MODEL_PART = "\r\nContent-Disposition: form-data; name=\"model\"\r\n(?:[^\r\n]+\r\n)*\r\n([^\r\n]+)\r\n"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def read(body)
|
|
15
|
+
case body
|
|
16
|
+
when String then body
|
|
17
|
+
when nil then ""
|
|
18
|
+
when Hash, Array then body.to_json
|
|
19
|
+
else body.to_str if body.respond_to?(:to_str)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def multipart_model_json(env, parser)
|
|
24
|
+
body = env.body
|
|
25
|
+
boundary = env.request_headers["Content-Type"].to_s[MULTIPART_BOUNDARY, 1]
|
|
26
|
+
return nil unless parser && boundary && body.respond_to?(:read) && body.respond_to?(:rewind)
|
|
27
|
+
|
|
28
|
+
model = scan_model(body, model_part(boundary))
|
|
29
|
+
model && { "model" => model }.to_json
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Logging.warn("Unable to read the model from a multipart request: #{e.class}: #{e.message}")
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def model_part(boundary)
|
|
38
|
+
Regexp.new("--#{Regexp.escape(boundary)}#{MODEL_PART}", Regexp::NOENCODING)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scan_model(io, pattern)
|
|
42
|
+
window = String.new(encoding: Encoding::BINARY)
|
|
43
|
+
while (chunk = io.read(65_536))
|
|
44
|
+
window << chunk.b
|
|
45
|
+
model = window[pattern, 1]
|
|
46
|
+
return model.force_encoding(Encoding::UTF_8) if model
|
|
47
|
+
|
|
48
|
+
window = window.byteslice([window.bytesize - 512, 0].max..)
|
|
49
|
+
end
|
|
50
|
+
ensure
|
|
51
|
+
io.rewind
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../../timing"
|
|
7
|
+
require_relative "body"
|
|
8
|
+
require_relative "response_reader"
|
|
9
|
+
require_relative "stream_tee"
|
|
10
|
+
|
|
11
|
+
module LlmCostTracker
|
|
12
|
+
module Middleware
|
|
13
|
+
class Faraday < ::Faraday::Middleware
|
|
14
|
+
class Exchange
|
|
15
|
+
def initialize(env, tags:)
|
|
16
|
+
@env = env
|
|
17
|
+
@tags = tags
|
|
18
|
+
@url = env.url.to_s
|
|
19
|
+
@parser = Parsers.find_for(@url)
|
|
20
|
+
read_request if @parser
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(app)
|
|
24
|
+
prepare_stream if @streaming
|
|
25
|
+
if tracked?
|
|
26
|
+
@context_tags, @metadata = tag_snapshot
|
|
27
|
+
enforce_budget if post?
|
|
28
|
+
end
|
|
29
|
+
@request = nil
|
|
30
|
+
perform(app, LlmCostTracker::Timing.now_monotonic)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def read_request
|
|
36
|
+
@body = Body.read(@env.body) || Body.multipart_model_json(@env, @parser)
|
|
37
|
+
@request = @parser.safe_json_parse(@body)
|
|
38
|
+
@model = @parser.model_for(@url, @request)
|
|
39
|
+
@streaming = @parser.streaming_request?(@url, @request)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
@request = {}
|
|
42
|
+
Logging.warn("Unable to read the request body: #{e.class}: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def prepare_stream
|
|
46
|
+
@body = request_stream_usage || @body
|
|
47
|
+
@stream_tap = StreamTee.install(@env, @parser)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def request_stream_usage
|
|
51
|
+
return unless LlmCostTracker.configuration.capture.request_stream_usage
|
|
52
|
+
return unless @parser.auto_enable_stream_usage?(@url, @request)
|
|
53
|
+
|
|
54
|
+
options = @request["stream_options"]
|
|
55
|
+
return if options.is_a?(Hash) && options.key?("include_usage")
|
|
56
|
+
|
|
57
|
+
@request["stream_options"] = (options || {}).merge("include_usage" => true)
|
|
58
|
+
@env.body = @request.to_json
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tracked?
|
|
62
|
+
@parser || (post? && Parsers.batch_submission?(@env.url))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def post? = @env.method == :post
|
|
66
|
+
|
|
67
|
+
def tag_snapshot
|
|
68
|
+
[tags_or_empty { LlmCostTracker::Tags::Context.tags }, tags_or_empty { resolved_tags }]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tags_or_empty
|
|
72
|
+
yield
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
Logging.warn("Error resolving request tags: #{e.class}: #{e.message}")
|
|
75
|
+
{}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def resolved_tags
|
|
79
|
+
return @tags.to_h unless @tags.respond_to?(:call)
|
|
80
|
+
|
|
81
|
+
(@tags.arity.zero? ? @tags.call : @tags.call(@env)).to_h
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def enforce_budget
|
|
85
|
+
Budget.enforce!(
|
|
86
|
+
provider: @parser&.provider_for(@url),
|
|
87
|
+
model: @model,
|
|
88
|
+
request: @request,
|
|
89
|
+
tags: Tracker.build_tags(context_tags: @context_tags, metadata: @metadata)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def perform(app, started_at)
|
|
94
|
+
received = false
|
|
95
|
+
app.call(@env).on_complete do |response_env|
|
|
96
|
+
received = true
|
|
97
|
+
record_response(response_env, (LlmCostTracker::Timing.elapsed_ms(started_at) if post?))
|
|
98
|
+
end
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
record_interruption(e, LlmCostTracker::Timing.elapsed_ms(started_at)) if @streaming && !received
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def record_response(response_env, latency_ms)
|
|
105
|
+
return unless @parser
|
|
106
|
+
|
|
107
|
+
reader = ResponseReader.new(parser: @parser, request_url: @url, request_body: @body, stream_tap: @stream_tap)
|
|
108
|
+
event = reader.call(response_env, streaming: @streaming)
|
|
109
|
+
return unless event
|
|
110
|
+
|
|
111
|
+
Tracker.record(event: event, latency_ms: latency_ms, metadata: @metadata, context_tags: @context_tags)
|
|
112
|
+
rescue *LlmCostTracker::CALLER_ERRORS
|
|
113
|
+
raise
|
|
114
|
+
rescue ActiveRecord::RecordNotUnique
|
|
115
|
+
nil
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
Logging.warn("Error processing #{@parser.provider_for(@url)} response for model #{@model || 'unknown'}: " \
|
|
118
|
+
"#{e.class}: #{e.message} (#{Array(e.backtrace).first})")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def record_interruption(error, latency_ms)
|
|
122
|
+
Tracker.record(
|
|
123
|
+
event: interrupted_event,
|
|
124
|
+
latency_ms: latency_ms,
|
|
125
|
+
metadata: interruption_metadata(error),
|
|
126
|
+
context_tags: @context_tags
|
|
127
|
+
)
|
|
128
|
+
rescue LlmCostTracker::TransactionAbortedError
|
|
129
|
+
raise
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def interrupted_event
|
|
135
|
+
request = @parser.safe_json_parse(@body)
|
|
136
|
+
Event.build(
|
|
137
|
+
provider: @parser.provider_for(@url),
|
|
138
|
+
model: @parser.model_for(@url, request) || Event::UNKNOWN_MODEL,
|
|
139
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
140
|
+
stream: true,
|
|
141
|
+
usage_source: Usage::Source::UNKNOWN
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def interruption_metadata(error)
|
|
146
|
+
metadata = (@metadata || {}).merge(stream_interrupted: true, stream_interrupted_error: error.class.name)
|
|
147
|
+
status = error.response_status if error.respond_to?(:response_status)
|
|
148
|
+
status ? metadata.merge(stream_interrupted_status: status) : metadata
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
require_relative "../../capture/sse"
|
|
7
|
+
require_relative "body"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Middleware
|
|
11
|
+
class Faraday < ::Faraday::Middleware
|
|
12
|
+
ResponseReader = ::Data.define(:parser, :request_url, :request_body, :stream_tap)
|
|
13
|
+
|
|
14
|
+
class ResponseReader
|
|
15
|
+
def call(response_env, streaming:)
|
|
16
|
+
streaming ? parse_stream(response_env) : parse_response(response_env)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def parse_response(response_env)
|
|
22
|
+
response_body = Body.read(response_env.body)
|
|
23
|
+
unless response_body
|
|
24
|
+
Logging.warn(
|
|
25
|
+
"Unable to read response body for #{Redaction.url(request_url)}; " \
|
|
26
|
+
"known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
|
|
27
|
+
"for custom clients."
|
|
28
|
+
)
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
parser.parse(
|
|
33
|
+
request_url: request_url,
|
|
34
|
+
request_body: request_body,
|
|
35
|
+
response_status: response_env.status,
|
|
36
|
+
response_body: response_body,
|
|
37
|
+
response_headers: response_env.response_headers
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_stream(response_env)
|
|
42
|
+
parser.parse_stream(
|
|
43
|
+
request_url: request_url,
|
|
44
|
+
request_body: request_body,
|
|
45
|
+
response_status: response_env.status,
|
|
46
|
+
events: stream_events(response_env),
|
|
47
|
+
response_headers: response_env.response_headers
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stream_events(response_env)
|
|
52
|
+
if stream_tap&.received?
|
|
53
|
+
events = stream_tap.events
|
|
54
|
+
return events unless stream_tap.overflowed? || stream_tap.failed?
|
|
55
|
+
else
|
|
56
|
+
body = Body.read(response_env.body)
|
|
57
|
+
return Capture::SSE.parse(body) if body.present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Logging.warn(capture_warning)
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def capture_warning
|
|
65
|
+
suffix = "recording usage_source=#{Usage::Source::UNKNOWN}. " \
|
|
66
|
+
"Use LlmCostTracker.track_stream for manual capture."
|
|
67
|
+
label = Redaction.url(request_url)
|
|
68
|
+
return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_tap&.overflowed?
|
|
69
|
+
|
|
70
|
+
"Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
require_relative "../../capture/stream_tap"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Middleware
|
|
9
|
+
class Faraday < ::Faraday::Middleware
|
|
10
|
+
module StreamTee
|
|
11
|
+
class << self
|
|
12
|
+
def install(env, parser)
|
|
13
|
+
request = env.request
|
|
14
|
+
return nil unless request
|
|
15
|
+
|
|
16
|
+
original = request.on_data
|
|
17
|
+
return nil unless original
|
|
18
|
+
|
|
19
|
+
tap = Capture::StreamTap.new(notable: parser.method(:retain_stream_event?),
|
|
20
|
+
trim: parser.method(:trim_stream_event))
|
|
21
|
+
request.on_data = proc do |chunk, size, chunk_env|
|
|
22
|
+
tap << chunk
|
|
23
|
+
forward(original, chunk, size, chunk_env)
|
|
24
|
+
end
|
|
25
|
+
tap
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def forward(callable, chunk, size, env)
|
|
34
|
+
arity = callable.arity
|
|
35
|
+
return callable.call(chunk, size, env) if arity.negative?
|
|
36
|
+
|
|
37
|
+
case arity
|
|
38
|
+
when 0, 1 then callable.call(chunk)
|
|
39
|
+
when 2 then callable.call(chunk, size)
|
|
40
|
+
else callable.call(chunk, size, env)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|