braintrust 0.0.12 → 0.1.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +213 -180
  3. data/exe/braintrust +143 -0
  4. data/lib/braintrust/contrib/anthropic/deprecated.rb +24 -0
  5. data/lib/braintrust/contrib/anthropic/instrumentation/common.rb +53 -0
  6. data/lib/braintrust/contrib/anthropic/instrumentation/messages.rb +232 -0
  7. data/lib/braintrust/contrib/anthropic/integration.rb +53 -0
  8. data/lib/braintrust/contrib/anthropic/patcher.rb +62 -0
  9. data/lib/braintrust/contrib/context.rb +56 -0
  10. data/lib/braintrust/contrib/integration.rb +160 -0
  11. data/lib/braintrust/contrib/openai/deprecated.rb +22 -0
  12. data/lib/braintrust/contrib/openai/instrumentation/chat.rb +298 -0
  13. data/lib/braintrust/contrib/openai/instrumentation/common.rb +134 -0
  14. data/lib/braintrust/contrib/openai/instrumentation/responses.rb +187 -0
  15. data/lib/braintrust/contrib/openai/integration.rb +58 -0
  16. data/lib/braintrust/contrib/openai/patcher.rb +130 -0
  17. data/lib/braintrust/contrib/patcher.rb +76 -0
  18. data/lib/braintrust/contrib/rails/railtie.rb +16 -0
  19. data/lib/braintrust/contrib/registry.rb +107 -0
  20. data/lib/braintrust/contrib/ruby_llm/deprecated.rb +45 -0
  21. data/lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb +464 -0
  22. data/lib/braintrust/contrib/ruby_llm/instrumentation/common.rb +58 -0
  23. data/lib/braintrust/contrib/ruby_llm/integration.rb +54 -0
  24. data/lib/braintrust/contrib/ruby_llm/patcher.rb +44 -0
  25. data/lib/braintrust/contrib/ruby_openai/deprecated.rb +24 -0
  26. data/lib/braintrust/contrib/ruby_openai/instrumentation/chat.rb +149 -0
  27. data/lib/braintrust/contrib/ruby_openai/instrumentation/common.rb +138 -0
  28. data/lib/braintrust/contrib/ruby_openai/instrumentation/responses.rb +146 -0
  29. data/lib/braintrust/contrib/ruby_openai/integration.rb +58 -0
  30. data/lib/braintrust/contrib/ruby_openai/patcher.rb +85 -0
  31. data/lib/braintrust/contrib/setup.rb +168 -0
  32. data/lib/braintrust/contrib/support/openai.rb +72 -0
  33. data/lib/braintrust/contrib/support/otel.rb +23 -0
  34. data/lib/braintrust/contrib.rb +205 -0
  35. data/lib/braintrust/internal/env.rb +33 -0
  36. data/lib/braintrust/internal/time.rb +44 -0
  37. data/lib/braintrust/setup.rb +50 -0
  38. data/lib/braintrust/state.rb +5 -0
  39. data/lib/braintrust/trace.rb +0 -51
  40. data/lib/braintrust/version.rb +1 -1
  41. data/lib/braintrust.rb +10 -1
  42. metadata +38 -7
  43. data/lib/braintrust/trace/contrib/anthropic.rb +0 -316
  44. data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +0 -377
  45. data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +0 -631
  46. data/lib/braintrust/trace/contrib/openai.rb +0 -611
  47. data/lib/braintrust/trace/tokens.rb +0 -109
@@ -1,611 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "opentelemetry/sdk"
4
- require "json"
5
- require_relative "../tokens"
6
-
7
- module Braintrust
8
- module Trace
9
- module OpenAI
10
- # Helper to safely set a JSON attribute on a span
11
- # Only sets the attribute if obj is present
12
- # @param span [OpenTelemetry::Trace::Span] the span to set attribute on
13
- # @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
14
- # @param obj [Object] the object to serialize to JSON
15
- # @return [void]
16
- def self.set_json_attr(span, attr_name, obj)
17
- return unless obj
18
- span.set_attribute(attr_name, JSON.generate(obj))
19
- end
20
-
21
- # Parse usage tokens from OpenAI API response
22
- # @param usage [Hash, Object] usage object from OpenAI response
23
- # @return [Hash<String, Integer>] metrics hash with normalized names
24
- def self.parse_usage_tokens(usage)
25
- Braintrust::Trace.parse_openai_usage_tokens(usage)
26
- end
27
-
28
- # Aggregate streaming chunks into a single response structure
29
- # Follows the Go SDK logic for aggregating deltas
30
- # @param chunks [Array<Hash>] array of chunk hashes from stream
31
- # @return [Hash] aggregated response with choices, usage, etc.
32
- def self.aggregate_streaming_chunks(chunks)
33
- return {} if chunks.empty?
34
-
35
- # Initialize aggregated structure
36
- aggregated = {
37
- id: nil,
38
- created: nil,
39
- model: nil,
40
- system_fingerprint: nil,
41
- choices: [],
42
- usage: nil
43
- }
44
-
45
- # Track aggregated content and tool_calls for each choice index
46
- choice_data = {}
47
-
48
- chunks.each do |chunk|
49
- # Capture top-level fields from any chunk that has them
50
- aggregated[:id] ||= chunk[:id]
51
- aggregated[:created] ||= chunk[:created]
52
- aggregated[:model] ||= chunk[:model]
53
- aggregated[:system_fingerprint] ||= chunk[:system_fingerprint]
54
-
55
- # Aggregate usage (usually only in last chunk if stream_options.include_usage is set)
56
- if chunk[:usage]
57
- aggregated[:usage] = chunk[:usage]
58
- end
59
-
60
- # Process choices
61
- next unless chunk[:choices].is_a?(Array)
62
- chunk[:choices].each do |choice|
63
- index = choice[:index] || 0
64
- choice_data[index] ||= {
65
- index: index,
66
- role: nil,
67
- content: +"",
68
- tool_calls: [],
69
- finish_reason: nil
70
- }
71
-
72
- delta = choice[:delta] || {}
73
-
74
- # Aggregate role (set once from first delta that has it)
75
- choice_data[index][:role] ||= delta[:role]
76
-
77
- # Aggregate content
78
- if delta[:content]
79
- choice_data[index][:content] << delta[:content]
80
- end
81
-
82
- # Aggregate tool_calls (similar to Go SDK logic)
83
- if delta[:tool_calls].is_a?(Array) && delta[:tool_calls].any?
84
- delta[:tool_calls].each do |tool_call_delta|
85
- # Check if this is a new tool call or continuation
86
- if tool_call_delta[:id] && !tool_call_delta[:id].empty?
87
- # New tool call
88
- choice_data[index][:tool_calls] << {
89
- id: tool_call_delta[:id],
90
- type: tool_call_delta[:type],
91
- function: {
92
- name: tool_call_delta.dig(:function, :name) || +"",
93
- arguments: tool_call_delta.dig(:function, :arguments) || +""
94
- }
95
- }
96
- elsif choice_data[index][:tool_calls].any?
97
- # Continuation - append arguments to last tool call
98
- last_tool_call = choice_data[index][:tool_calls].last
99
- if tool_call_delta.dig(:function, :arguments)
100
- last_tool_call[:function][:arguments] << tool_call_delta[:function][:arguments]
101
- end
102
- end
103
- end
104
- end
105
-
106
- # Capture finish_reason
107
- if choice[:finish_reason]
108
- choice_data[index][:finish_reason] = choice[:finish_reason]
109
- end
110
- end
111
- end
112
-
113
- # Build final choices array
114
- aggregated[:choices] = choice_data.values.sort_by { |c| c[:index] }.map do |choice|
115
- message = {
116
- role: choice[:role],
117
- content: choice[:content].empty? ? nil : choice[:content]
118
- }
119
-
120
- # Add tool_calls to message if any
121
- message[:tool_calls] = choice[:tool_calls] if choice[:tool_calls].any?
122
-
123
- {
124
- index: choice[:index],
125
- message: message,
126
- finish_reason: choice[:finish_reason]
127
- }
128
- end
129
-
130
- aggregated
131
- end
132
-
133
- # Wrap an OpenAI::Client to automatically create spans for chat completions and responses
134
- # Supports both synchronous and streaming requests
135
- # @param client [OpenAI::Client] the OpenAI client to wrap
136
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
137
- def self.wrap(client, tracer_provider: nil)
138
- tracer_provider ||= ::OpenTelemetry.tracer_provider
139
-
140
- # Wrap chat completions
141
- wrap_chat_completions(client, tracer_provider)
142
-
143
- # Wrap responses API if available
144
- wrap_responses(client, tracer_provider) if client.respond_to?(:responses)
145
-
146
- client
147
- end
148
-
149
- # Wrap chat completions API
150
- # @param client [OpenAI::Client] the OpenAI client
151
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
152
- def self.wrap_chat_completions(client, tracer_provider)
153
- # Create a wrapper module that intercepts chat.completions.create
154
- wrapper = Module.new do
155
- define_method(:create) do |**params|
156
- tracer = tracer_provider.tracer("braintrust")
157
-
158
- tracer.in_span("Chat Completion") do |span|
159
- # Track start time for time_to_first_token
160
- start_time = Time.now
161
-
162
- # Initialize metadata hash
163
- metadata = {
164
- "provider" => "openai",
165
- "endpoint" => "/v1/chat/completions"
166
- }
167
-
168
- # Capture request metadata fields
169
- metadata_fields = %i[
170
- model frequency_penalty logit_bias logprobs max_tokens n
171
- presence_penalty response_format seed service_tier stop
172
- stream stream_options temperature top_p top_logprobs
173
- tools tool_choice parallel_tool_calls user functions function_call
174
- ]
175
-
176
- metadata_fields.each do |field|
177
- metadata[field.to_s] = params[field] if params.key?(field)
178
- end
179
-
180
- # Set input messages as JSON
181
- # Pass through all message fields to preserve tool_calls, tool_call_id, name, etc.
182
- if params[:messages]
183
- messages_array = params[:messages].map(&:to_h)
184
- span.set_attribute("braintrust.input_json", JSON.generate(messages_array))
185
- end
186
-
187
- # Call the original method
188
- response = super(**params)
189
-
190
- # Calculate time to first token
191
- time_to_first_token = Time.now - start_time
192
-
193
- # Set output (choices) as JSON
194
- # Use to_h to get the raw structure with all fields (including tool_calls)
195
- if response.respond_to?(:choices) && response.choices&.any?
196
- choices_array = response.choices.map(&:to_h)
197
- span.set_attribute("braintrust.output_json", JSON.generate(choices_array))
198
- end
199
-
200
- # Set metrics (token usage with advanced details)
201
- metrics = {}
202
- if response.respond_to?(:usage) && response.usage
203
- metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(response.usage)
204
- end
205
- # Add time_to_first_token metric
206
- metrics["time_to_first_token"] = time_to_first_token
207
- span.set_attribute("braintrust.metrics", JSON.generate(metrics)) unless metrics.empty?
208
-
209
- # Add response metadata fields
210
- metadata["id"] = response.id if response.respond_to?(:id) && response.id
211
- metadata["created"] = response.created if response.respond_to?(:created) && response.created
212
- metadata["system_fingerprint"] = response.system_fingerprint if response.respond_to?(:system_fingerprint) && response.system_fingerprint
213
- metadata["service_tier"] = response.service_tier if response.respond_to?(:service_tier) && response.service_tier
214
-
215
- # Set metadata ONCE at the end with complete hash
216
- span.set_attribute("braintrust.metadata", JSON.generate(metadata))
217
-
218
- response
219
- end
220
- end
221
-
222
- # Wrap stream_raw for streaming chat completions
223
- define_method(:stream_raw) do |**params|
224
- tracer = tracer_provider.tracer("braintrust")
225
- aggregated_chunks = []
226
- start_time = Time.now
227
- time_to_first_token = nil
228
- metadata = {
229
- "provider" => "openai",
230
- "endpoint" => "/v1/chat/completions"
231
- }
232
-
233
- # Start span with proper context (will be child of current span if any)
234
- span = tracer.start_span("Chat Completion")
235
-
236
- # Capture request metadata fields
237
- metadata_fields = %i[
238
- model frequency_penalty logit_bias logprobs max_tokens n
239
- presence_penalty response_format seed service_tier stop
240
- stream stream_options temperature top_p top_logprobs
241
- tools tool_choice parallel_tool_calls user functions function_call
242
- ]
243
-
244
- metadata_fields.each do |field|
245
- metadata[field.to_s] = params[field] if params.key?(field)
246
- end
247
- metadata["stream"] = true # Explicitly mark as streaming
248
-
249
- # Set input messages as JSON
250
- if params[:messages]
251
- messages_array = params[:messages].map(&:to_h)
252
- span.set_attribute("braintrust.input_json", JSON.generate(messages_array))
253
- end
254
-
255
- # Set initial metadata
256
- span.set_attribute("braintrust.metadata", JSON.generate(metadata))
257
-
258
- # Call the original stream_raw method with error handling
259
- begin
260
- stream = super(**params)
261
- rescue => e
262
- # Record exception if stream creation fails
263
- span.record_exception(e)
264
- span.status = ::OpenTelemetry::Trace::Status.error("OpenAI API error: #{e.message}")
265
- span.finish
266
- raise
267
- end
268
-
269
- # Wrap the stream to aggregate chunks
270
- original_each = stream.method(:each)
271
- stream.define_singleton_method(:each) do |&block|
272
- original_each.call do |chunk|
273
- # Capture time to first token on first chunk
274
- time_to_first_token ||= Time.now - start_time
275
- aggregated_chunks << chunk.to_h
276
- block&.call(chunk)
277
- end
278
- rescue => e
279
- # Record exception if streaming fails
280
- span.record_exception(e)
281
- span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
282
- raise
283
- ensure
284
- # Always aggregate whatever chunks we collected and finish span
285
- # This runs on normal completion, break, or exception
286
- unless aggregated_chunks.empty?
287
- aggregated_output = Braintrust::Trace::OpenAI.aggregate_streaming_chunks(aggregated_chunks)
288
- Braintrust::Trace::OpenAI.set_json_attr(span, "braintrust.output_json", aggregated_output[:choices])
289
-
290
- # Set metrics if usage is included (requires stream_options.include_usage)
291
- metrics = {}
292
- if aggregated_output[:usage]
293
- metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(aggregated_output[:usage])
294
- end
295
- # Add time_to_first_token metric
296
- metrics["time_to_first_token"] = time_to_first_token || 0.0
297
- Braintrust::Trace::OpenAI.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
298
-
299
- # Update metadata with response fields
300
- metadata["id"] = aggregated_output[:id] if aggregated_output[:id]
301
- metadata["created"] = aggregated_output[:created] if aggregated_output[:created]
302
- metadata["model"] = aggregated_output[:model] if aggregated_output[:model]
303
- metadata["system_fingerprint"] = aggregated_output[:system_fingerprint] if aggregated_output[:system_fingerprint]
304
- Braintrust::Trace::OpenAI.set_json_attr(span, "braintrust.metadata", metadata)
305
- end
306
-
307
- span.finish
308
- end
309
-
310
- stream
311
- end
312
-
313
- # Wrap stream for streaming chat completions (returns ChatCompletionStream with convenience methods)
314
- define_method(:stream) do |**params|
315
- tracer = tracer_provider.tracer("braintrust")
316
- start_time = Time.now
317
- time_to_first_token = nil
318
- metadata = {
319
- "provider" => "openai",
320
- "endpoint" => "/v1/chat/completions"
321
- }
322
-
323
- # Start span with proper context (will be child of current span if any)
324
- span = tracer.start_span("Chat Completion")
325
-
326
- # Capture request metadata fields
327
- metadata_fields = %i[
328
- model frequency_penalty logit_bias logprobs max_tokens n
329
- presence_penalty response_format seed service_tier stop
330
- stream stream_options temperature top_p top_logprobs
331
- tools tool_choice parallel_tool_calls user functions function_call
332
- ]
333
-
334
- metadata_fields.each do |field|
335
- metadata[field.to_s] = params[field] if params.key?(field)
336
- end
337
- metadata["stream"] = true # Explicitly mark as streaming
338
-
339
- # Set input messages as JSON
340
- if params[:messages]
341
- messages_array = params[:messages].map(&:to_h)
342
- span.set_attribute("braintrust.input_json", JSON.generate(messages_array))
343
- end
344
-
345
- # Set initial metadata
346
- span.set_attribute("braintrust.metadata", JSON.generate(metadata))
347
-
348
- # Call the original stream method with error handling
349
- begin
350
- stream = super(**params)
351
- rescue => e
352
- # Record exception if stream creation fails
353
- span.record_exception(e)
354
- span.status = ::OpenTelemetry::Trace::Status.error("OpenAI API error: #{e.message}")
355
- span.finish
356
- raise
357
- end
358
-
359
- # Local helper for setting JSON attributes
360
- set_json_attr = ->(attr_name, obj) { Braintrust::Trace::OpenAI.set_json_attr(span, attr_name, obj) }
361
-
362
- # Helper to extract metadata from SDK's internal snapshot
363
- extract_stream_metadata = lambda do
364
- # Access the SDK's internal accumulated completion snapshot
365
- snapshot = stream.current_completion_snapshot
366
- return unless snapshot
367
-
368
- # Set output from accumulated choices
369
- if snapshot.choices&.any?
370
- choices_array = snapshot.choices.map(&:to_h)
371
- set_json_attr.call("braintrust.output_json", choices_array)
372
- end
373
-
374
- # Set metrics if usage is available
375
- metrics = {}
376
- if snapshot.usage
377
- metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(snapshot.usage)
378
- end
379
- # Add time_to_first_token metric
380
- metrics["time_to_first_token"] = time_to_first_token || 0.0
381
- set_json_attr.call("braintrust.metrics", metrics) unless metrics.empty?
382
-
383
- # Update metadata with response fields
384
- metadata["id"] = snapshot.id if snapshot.respond_to?(:id) && snapshot.id
385
- metadata["created"] = snapshot.created if snapshot.respond_to?(:created) && snapshot.created
386
- metadata["model"] = snapshot.model if snapshot.respond_to?(:model) && snapshot.model
387
- metadata["system_fingerprint"] = snapshot.system_fingerprint if snapshot.respond_to?(:system_fingerprint) && snapshot.system_fingerprint
388
- set_json_attr.call("braintrust.metadata", metadata)
389
- end
390
-
391
- # Prevent double-finish of span
392
- finish_braintrust_span = lambda do
393
- return if stream.instance_variable_get(:@braintrust_span_finished)
394
- stream.instance_variable_set(:@braintrust_span_finished, true)
395
- extract_stream_metadata.call
396
- span.finish
397
- end
398
-
399
- # Wrap .each() method - this is the core consumption method
400
- original_each = stream.method(:each)
401
- stream.define_singleton_method(:each) do |&block|
402
- original_each.call do |chunk|
403
- # Capture time to first token on first chunk
404
- time_to_first_token ||= Time.now - start_time
405
- block&.call(chunk)
406
- end
407
- rescue => e
408
- span.record_exception(e)
409
- span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
410
- raise
411
- ensure
412
- finish_braintrust_span.call
413
- end
414
-
415
- # Wrap .text() method - returns enumerable for text deltas
416
- original_text = stream.method(:text)
417
- stream.define_singleton_method(:text) do
418
- text_enum = original_text.call
419
- # Wrap the returned enumerable's .each method
420
- original_text_each = text_enum.method(:each)
421
- text_enum.define_singleton_method(:each) do |&block|
422
- original_text_each.call do |delta|
423
- # Capture time to first token on first delta
424
- time_to_first_token ||= Time.now - start_time
425
- block&.call(delta)
426
- end
427
- rescue => e
428
- span.record_exception(e)
429
- span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
430
- raise
431
- ensure
432
- finish_braintrust_span.call
433
- end
434
- text_enum
435
- end
436
-
437
- stream
438
- end
439
- end
440
-
441
- # Prepend the wrapper to the completions resource
442
- client.chat.completions.singleton_class.prepend(wrapper)
443
- end
444
-
445
- # Wrap responses API
446
- # @param client [OpenAI::Client] the OpenAI client
447
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
448
- def self.wrap_responses(client, tracer_provider)
449
- # Create a wrapper module that intercepts responses.create and responses.stream
450
- wrapper = Module.new do
451
- # Wrap non-streaming create method
452
- define_method(:create) do |**params|
453
- tracer = tracer_provider.tracer("braintrust")
454
-
455
- tracer.in_span("openai.responses.create") do |span|
456
- # Initialize metadata hash
457
- metadata = {
458
- "provider" => "openai",
459
- "endpoint" => "/v1/responses"
460
- }
461
-
462
- # Capture request metadata fields
463
- metadata_fields = %i[
464
- model instructions modalities tools parallel_tool_calls
465
- tool_choice temperature max_tokens top_p frequency_penalty
466
- presence_penalty seed user metadata store response_format
467
- ]
468
-
469
- metadata_fields.each do |field|
470
- metadata[field.to_s] = params[field] if params.key?(field)
471
- end
472
-
473
- # Set input as JSON
474
- if params[:input]
475
- span.set_attribute("braintrust.input_json", JSON.generate(params[:input]))
476
- end
477
-
478
- # Call the original method
479
- response = super(**params)
480
-
481
- # Set output as JSON
482
- if response.respond_to?(:output) && response.output
483
- span.set_attribute("braintrust.output_json", JSON.generate(response.output))
484
- end
485
-
486
- # Set metrics (token usage)
487
- if response.respond_to?(:usage) && response.usage
488
- metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(response.usage)
489
- span.set_attribute("braintrust.metrics", JSON.generate(metrics)) unless metrics.empty?
490
- end
491
-
492
- # Add response metadata fields
493
- metadata["id"] = response.id if response.respond_to?(:id) && response.id
494
-
495
- # Set metadata ONCE at the end with complete hash
496
- span.set_attribute("braintrust.metadata", JSON.generate(metadata))
497
-
498
- response
499
- end
500
- end
501
-
502
- # Wrap streaming method
503
- define_method(:stream) do |**params|
504
- tracer = tracer_provider.tracer("braintrust")
505
- aggregated_events = []
506
- metadata = {
507
- "provider" => "openai",
508
- "endpoint" => "/v1/responses",
509
- "stream" => true
510
- }
511
-
512
- # Start span with proper context
513
- span = tracer.start_span("openai.responses.create")
514
-
515
- # Capture request metadata fields
516
- metadata_fields = %i[
517
- model instructions modalities tools parallel_tool_calls
518
- tool_choice temperature max_tokens top_p frequency_penalty
519
- presence_penalty seed user metadata store response_format
520
- ]
521
-
522
- metadata_fields.each do |field|
523
- metadata[field.to_s] = params[field] if params.key?(field)
524
- end
525
-
526
- # Set input as JSON
527
- if params[:input]
528
- span.set_attribute("braintrust.input_json", JSON.generate(params[:input]))
529
- end
530
-
531
- # Set initial metadata
532
- span.set_attribute("braintrust.metadata", JSON.generate(metadata))
533
-
534
- # Call the original stream method with error handling
535
- begin
536
- stream = super(**params)
537
- rescue => e
538
- # Record exception if stream creation fails
539
- span.record_exception(e)
540
- span.status = ::OpenTelemetry::Trace::Status.error("OpenAI API error: #{e.message}")
541
- span.finish
542
- raise
543
- end
544
-
545
- # Wrap the stream to aggregate events
546
- original_each = stream.method(:each)
547
- stream.define_singleton_method(:each) do |&block|
548
- original_each.call do |event|
549
- # Store the actual event object (not converted to hash)
550
- aggregated_events << event
551
- block&.call(event)
552
- end
553
- rescue => e
554
- # Record exception if streaming fails
555
- span.record_exception(e)
556
- span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
557
- raise
558
- ensure
559
- # Always aggregate whatever events we collected and finish span
560
- unless aggregated_events.empty?
561
- aggregated_output = Braintrust::Trace::OpenAI.aggregate_responses_events(aggregated_events)
562
- Braintrust::Trace::OpenAI.set_json_attr(span, "braintrust.output_json", aggregated_output[:output]) if aggregated_output[:output]
563
-
564
- # Set metrics if usage is included
565
- if aggregated_output[:usage]
566
- metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(aggregated_output[:usage])
567
- Braintrust::Trace::OpenAI.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
568
- end
569
-
570
- # Update metadata with response fields
571
- metadata["id"] = aggregated_output[:id] if aggregated_output[:id]
572
- Braintrust::Trace::OpenAI.set_json_attr(span, "braintrust.metadata", metadata)
573
- end
574
-
575
- span.finish
576
- end
577
-
578
- stream
579
- end
580
- end
581
-
582
- # Prepend the wrapper to the responses resource
583
- client.responses.singleton_class.prepend(wrapper)
584
- end
585
-
586
- # Aggregate responses streaming events into a single response structure
587
- # Follows similar logic to Python SDK's _postprocess_streaming_results
588
- # @param events [Array] array of event objects from stream
589
- # @return [Hash] aggregated response with output, usage, etc.
590
- def self.aggregate_responses_events(events)
591
- return {} if events.empty?
592
-
593
- # Find the response.completed event which has the final response
594
- completed_event = events.find { |e| e.respond_to?(:type) && e.type == :"response.completed" }
595
-
596
- if completed_event&.respond_to?(:response)
597
- response = completed_event.response
598
- # Convert the response object to a hash-like structure for logging
599
- return {
600
- id: response.respond_to?(:id) ? response.id : nil,
601
- output: response.respond_to?(:output) ? response.output : nil,
602
- usage: response.respond_to?(:usage) ? response.usage : nil
603
- }
604
- end
605
-
606
- # Fallback if no completed event found
607
- {}
608
- end
609
- end
610
- end
611
- end