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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. 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.connection.supports_insert_returning?
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
- attributes = {
51
- event_id: event.event_id,
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
- Storable.clean(attributes.merge(event.token_usage.to_h).merge(total_cost: event.cost&.total))
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
- kind: line_item.kind,
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.connection
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.connection))
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