braintrust 0.0.4 → 0.0.6

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.
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "json"
5
+ require_relative "../../../tokens"
6
+ require_relative "../../../../logger"
7
+
8
+ module Braintrust
9
+ module Trace
10
+ module Contrib
11
+ module Github
12
+ module Crmne
13
+ module RubyLLM
14
+ # Helper to safely set a JSON attribute on a span
15
+ # Only sets the attribute if obj is present
16
+ # @param span [OpenTelemetry::Trace::Span] the span to set attribute on
17
+ # @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
18
+ # @param obj [Object] the object to serialize to JSON
19
+ # @return [void]
20
+ def self.set_json_attr(span, attr_name, obj)
21
+ return unless obj
22
+ span.set_attribute(attr_name, JSON.generate(obj))
23
+ rescue => e
24
+ Log.debug("Failed to serialize #{attr_name}: #{e.message}")
25
+ end
26
+
27
+ # Parse usage tokens from RubyLLM response
28
+ # RubyLLM uses Anthropic-style field naming (input_tokens, output_tokens)
29
+ # @param usage [Hash, Object] usage object from RubyLLM response
30
+ # @return [Hash<String, Integer>] metrics hash with normalized names
31
+ def self.parse_usage_tokens(usage)
32
+ Braintrust::Trace.parse_anthropic_usage_tokens(usage)
33
+ end
34
+
35
+ # Wrap RubyLLM to automatically create spans for chat requests
36
+ # Supports both synchronous and streaming requests
37
+ #
38
+ # Usage:
39
+ # # Wrap the class once (affects all future instances):
40
+ # Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
41
+ #
42
+ # # Or wrap a specific instance:
43
+ # chat = RubyLLM.chat(model: "gpt-4o-mini")
44
+ # Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(chat)
45
+ #
46
+ # @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to wrap (if nil, wraps the class)
47
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
48
+ def self.wrap(chat = nil, tracer_provider: nil)
49
+ tracer_provider ||= ::OpenTelemetry.tracer_provider
50
+
51
+ # If no chat instance provided, wrap the class globally via initialize hook
52
+ if chat.nil?
53
+ return if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
54
+
55
+ # Create module that wraps initialize to auto-wrap each new instance
56
+ wrapper_module = Module.new do
57
+ define_method(:initialize) do |*args, **kwargs, &block|
58
+ super(*args, **kwargs, &block)
59
+ # Auto-wrap this instance during initialization
60
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(self, tracer_provider: tracer_provider)
61
+ self
62
+ end
63
+ end
64
+
65
+ # Store reference to wrapper module for cleanup
66
+ ::RubyLLM::Chat.instance_variable_set(:@braintrust_wrapper_module, wrapper_module)
67
+ ::RubyLLM::Chat.prepend(wrapper_module)
68
+ return nil
69
+ end
70
+
71
+ # Check if already wrapped to make this idempotent
72
+ return chat if chat.instance_variable_get(:@braintrust_wrapped)
73
+
74
+ # Create a wrapper module that intercepts chat.ask
75
+ wrapper = create_wrapper_module(tracer_provider)
76
+
77
+ # Mark as wrapped and prepend the wrapper to the chat instance
78
+ chat.instance_variable_set(:@braintrust_wrapped, true)
79
+ chat.singleton_class.prepend(wrapper)
80
+ chat
81
+ end
82
+
83
+ # Unwrap RubyLLM to remove Braintrust tracing
84
+ # For class-level unwrapping, removes the initialize override from the wrapper module
85
+ # For instance-level unwrapping, clears the wrapped flag
86
+ #
87
+ # @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to unwrap (if nil, unwraps the class)
88
+ def self.unwrap(chat = nil)
89
+ # If no chat instance provided, unwrap the class globally
90
+ if chat.nil?
91
+ if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
92
+ wrapper_module = ::RubyLLM::Chat.instance_variable_get(:@braintrust_wrapper_module)
93
+ # Redefine initialize to just call super (disables auto-wrapping)
94
+ # We can't actually remove a prepended module, so we make it a no-op
95
+ wrapper_module.module_eval do
96
+ define_method(:initialize) do |*args, **kwargs, &block|
97
+ super(*args, **kwargs, &block)
98
+ end
99
+ end
100
+ ::RubyLLM::Chat.remove_instance_variable(:@braintrust_wrapper_module)
101
+ end
102
+ return nil
103
+ end
104
+
105
+ # Unwrap instance
106
+ chat.remove_instance_variable(:@braintrust_wrapped) if chat.instance_variable_defined?(:@braintrust_wrapped)
107
+ chat
108
+ end
109
+
110
+ # Wrap the RubyLLM::Chat class globally
111
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
112
+ def self.wrap_class(tracer_provider)
113
+ return unless defined?(::RubyLLM::Chat)
114
+
115
+ wrapper = create_wrapper_module(tracer_provider)
116
+ ::RubyLLM::Chat.prepend(wrapper)
117
+ end
118
+
119
+ # Create the wrapper module that intercepts chat.ask
120
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
121
+ # @return [Module] the wrapper module
122
+ def self.create_wrapper_module(tracer_provider)
123
+ Module.new do
124
+ define_method(:ask) do |prompt = nil, **params, &block|
125
+ tracer = tracer_provider.tracer("braintrust")
126
+
127
+ if block
128
+ # Handle streaming request
129
+ wrapped_block = proc do |chunk|
130
+ block.call(chunk)
131
+ end
132
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_streaming_ask(self, tracer, prompt, params, block) do |aggregated_chunks|
133
+ super(prompt, **params) do |chunk|
134
+ aggregated_chunks << chunk
135
+ wrapped_block.call(chunk)
136
+ end
137
+ end
138
+ else
139
+ # Handle non-streaming request
140
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_non_streaming_ask(self, tracer, prompt, params) do
141
+ super(prompt, **params)
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # Handle streaming chat request with tracing
149
+ # @param chat [RubyLLM::Chat] the chat instance
150
+ # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
151
+ # @param prompt [String, nil] the user prompt
152
+ # @param params [Hash] additional parameters
153
+ # @param block [Proc] the streaming block
154
+ def self.handle_streaming_ask(chat, tracer, prompt, params, block)
155
+ # Start span immediately for accurate timing
156
+ span = tracer.start_span("ruby_llm.chat.ask")
157
+
158
+ aggregated_chunks = []
159
+
160
+ # Extract metadata and build input messages
161
+ metadata = extract_metadata(chat, stream: true)
162
+ input_messages = build_input_messages(chat, prompt)
163
+
164
+ # Set input and metadata
165
+ set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
166
+ set_json_attr(span, "braintrust.metadata", metadata)
167
+
168
+ # Call original method, passing aggregated_chunks to the block
169
+ begin
170
+ result = yield aggregated_chunks
171
+ rescue => e
172
+ span.record_exception(e)
173
+ span.status = ::OpenTelemetry::Trace::Status.error("RubyLLM error: #{e.message}")
174
+ span.finish
175
+ raise
176
+ end
177
+
178
+ # Set output and metrics from aggregated chunks
179
+ capture_streaming_output(span, aggregated_chunks, result)
180
+ span.finish
181
+ result
182
+ end
183
+
184
+ # Handle non-streaming chat request with tracing
185
+ # @param chat [RubyLLM::Chat] the chat instance
186
+ # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
187
+ # @param prompt [String, nil] the user prompt
188
+ # @param params [Hash] additional parameters
189
+ def self.handle_non_streaming_ask(chat, tracer, prompt, params)
190
+ # Start span immediately for accurate timing
191
+ span = tracer.start_span("ruby_llm.chat.ask")
192
+
193
+ begin
194
+ # Extract metadata and build input messages
195
+ metadata = extract_metadata(chat)
196
+ input_messages = build_input_messages(chat, prompt)
197
+ set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
198
+
199
+ # Remember message count before the call (for tool call detection)
200
+ messages_before_count = (chat.respond_to?(:messages) && chat.messages) ? chat.messages.length : 0
201
+
202
+ # Call the original method
203
+ response = yield
204
+
205
+ # Capture output and metrics
206
+ capture_non_streaming_output(span, chat, response, messages_before_count)
207
+
208
+ # Set metadata
209
+ set_json_attr(span, "braintrust.metadata", metadata)
210
+
211
+ response
212
+ ensure
213
+ span.finish
214
+ end
215
+ end
216
+
217
+ # Extract metadata from chat instance (provider, model, tools, stream flag)
218
+ # @param chat [RubyLLM::Chat] the chat instance
219
+ # @param stream [Boolean] whether this is a streaming request
220
+ # @return [Hash] metadata hash
221
+ def self.extract_metadata(chat, stream: false)
222
+ metadata = {"provider" => "ruby_llm"}
223
+ metadata["stream"] = true if stream
224
+
225
+ # Extract model
226
+ if chat.respond_to?(:model) && chat.model
227
+ model = chat.model.respond_to?(:id) ? chat.model.id : chat.model.to_s
228
+ metadata["model"] = model
229
+ end
230
+
231
+ # Extract tools (only for non-streaming)
232
+ if !stream && chat.respond_to?(:tools) && chat.tools&.any?
233
+ metadata["tools"] = extract_tools_metadata(chat)
234
+ end
235
+
236
+ metadata
237
+ end
238
+
239
+ # Extract tools metadata from chat instance
240
+ # @param chat [RubyLLM::Chat] the chat instance
241
+ # @return [Array<Hash>] array of tool schemas
242
+ def self.extract_tools_metadata(chat)
243
+ provider = chat.instance_variable_get(:@provider) if chat.instance_variable_defined?(:@provider)
244
+
245
+ chat.tools.map do |_name, tool|
246
+ format_tool_schema(tool, provider)
247
+ end
248
+ end
249
+
250
+ # Format a tool into OpenAI-compatible schema
251
+ # @param tool [Object] the tool object
252
+ # @param provider [Object, nil] the provider instance
253
+ # @return [Hash] tool schema
254
+ def self.format_tool_schema(tool, provider)
255
+ tool_schema = nil
256
+
257
+ # Use provider-specific tool_for method if available
258
+ if provider
259
+ begin
260
+ tool_schema = if provider.is_a?(::RubyLLM::Providers::OpenAI)
261
+ ::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
262
+ elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
263
+ ::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
264
+ elsif tool.respond_to?(:params_schema) && tool.params_schema
265
+ build_basic_tool_schema(tool)
266
+ else
267
+ build_minimal_tool_schema(tool)
268
+ end
269
+ rescue NameError, ArgumentError => e
270
+ # If provider-specific tool_for fails, fall back to basic format
271
+ Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
272
+ tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
273
+ end
274
+ else
275
+ # No provider, use basic format with params_schema if available
276
+ tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
277
+ end
278
+
279
+ # Strip RubyLLM-specific fields to match native OpenAI format
280
+ # Handle both symbol and string keys
281
+ function_key = tool_schema&.key?(:function) ? :function : "function"
282
+ if tool_schema && tool_schema[function_key]
283
+ tool_params = tool_schema[function_key][:parameters] || tool_schema[function_key]["parameters"]
284
+ if tool_params.is_a?(Hash)
285
+ tool_params.delete("strict")
286
+ tool_params.delete(:strict)
287
+ tool_params.delete("additionalProperties")
288
+ tool_params.delete(:additionalProperties)
289
+ end
290
+ end
291
+
292
+ tool_schema
293
+ end
294
+
295
+ # Build a basic tool schema with parameters
296
+ # @param tool [Object] the tool object
297
+ # @return [Hash] tool schema
298
+ def self.build_basic_tool_schema(tool)
299
+ {
300
+ "type" => "function",
301
+ "function" => {
302
+ "name" => tool.name.to_s,
303
+ "description" => tool.description,
304
+ "parameters" => tool.params_schema
305
+ }
306
+ }
307
+ end
308
+
309
+ # Build a minimal tool schema without parameters
310
+ # @param tool [Object] the tool object
311
+ # @return [Hash] tool schema
312
+ def self.build_minimal_tool_schema(tool)
313
+ {
314
+ "type" => "function",
315
+ "function" => {
316
+ "name" => tool.name.to_s,
317
+ "description" => tool.description,
318
+ "parameters" => {}
319
+ }
320
+ }
321
+ end
322
+
323
+ # Build input messages array from chat history and prompt
324
+ # @param chat [RubyLLM::Chat] the chat instance
325
+ # @param prompt [String, nil] the user prompt
326
+ # @return [Array<Hash>] array of message hashes
327
+ def self.build_input_messages(chat, prompt)
328
+ input_messages = []
329
+
330
+ # Add conversation history
331
+ if chat.respond_to?(:messages) && chat.messages&.any?
332
+ input_messages = chat.messages.map { |m| m.respond_to?(:to_h) ? m.to_h : m }
333
+ end
334
+
335
+ # Add current prompt
336
+ input_messages << {role: "user", content: prompt} if prompt
337
+
338
+ input_messages
339
+ end
340
+
341
+ # Capture streaming output and metrics
342
+ # @param span [OpenTelemetry::Trace::Span] the span
343
+ # @param aggregated_chunks [Array] the aggregated chunks
344
+ # @param result [Object] the result object
345
+ def self.capture_streaming_output(span, aggregated_chunks, result)
346
+ return if aggregated_chunks.empty?
347
+
348
+ # Aggregate content from chunks
349
+ aggregated_content = aggregated_chunks.map { |c|
350
+ c.respond_to?(:content) ? c.content : c.to_s
351
+ }.join
352
+
353
+ output = [{
354
+ role: "assistant",
355
+ content: aggregated_content
356
+ }]
357
+ set_json_attr(span, "braintrust.output_json", output)
358
+
359
+ # Try to extract usage from the result
360
+ if result.respond_to?(:usage) && result.usage
361
+ metrics = parse_usage_tokens(result.usage)
362
+ set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
363
+ end
364
+ end
365
+
366
+ # Capture non-streaming output and metrics
367
+ # @param span [OpenTelemetry::Trace::Span] the span
368
+ # @param chat [RubyLLM::Chat] the chat instance
369
+ # @param response [Object] the response object
370
+ # @param messages_before_count [Integer] message count before the call
371
+ def self.capture_non_streaming_output(span, chat, response, messages_before_count)
372
+ return unless response
373
+
374
+ # Build message object from response
375
+ message = {
376
+ "role" => "assistant",
377
+ "content" => nil
378
+ }
379
+
380
+ # Add content if it's a simple text response
381
+ if response.respond_to?(:content) && response.content && !response.content.empty?
382
+ message["content"] = response.content
383
+ end
384
+
385
+ # Check if there are tool calls in the messages history
386
+ if chat.respond_to?(:messages) && chat.messages
387
+ assistant_msg = chat.messages[(messages_before_count + 1)..].find { |m|
388
+ m.role.to_s == "assistant" && m.respond_to?(:tool_calls) && m.tool_calls&.any?
389
+ }
390
+
391
+ if assistant_msg&.tool_calls&.any?
392
+ message["tool_calls"] = format_tool_calls(assistant_msg.tool_calls)
393
+ message["content"] = nil
394
+ end
395
+ end
396
+
397
+ # Format as OpenAI choices[] structure
398
+ output = [{
399
+ "index" => 0,
400
+ "message" => message,
401
+ "finish_reason" => message["tool_calls"] ? "tool_calls" : "stop"
402
+ }]
403
+
404
+ set_json_attr(span, "braintrust.output_json", output)
405
+
406
+ # Set metrics (token usage)
407
+ if response.respond_to?(:to_h)
408
+ response_hash = response.to_h
409
+ usage = {
410
+ "input_tokens" => response_hash[:input_tokens],
411
+ "output_tokens" => response_hash[:output_tokens],
412
+ "cached_tokens" => response_hash[:cached_tokens],
413
+ "cache_creation_tokens" => response_hash[:cache_creation_tokens]
414
+ }.compact
415
+
416
+ unless usage.empty?
417
+ metrics = parse_usage_tokens(usage)
418
+ set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
419
+ end
420
+ end
421
+ end
422
+
423
+ # Format tool calls into OpenAI format
424
+ # @param tool_calls [Hash, Array] the tool calls
425
+ # @return [Array<Hash>] formatted tool calls
426
+ def self.format_tool_calls(tool_calls)
427
+ tool_calls.map do |_id, tc|
428
+ # Ensure arguments is a JSON string (OpenAI format)
429
+ args = tc.arguments
430
+ args_string = args.is_a?(String) ? args : JSON.generate(args)
431
+
432
+ {
433
+ "id" => tc.id,
434
+ "type" => "function",
435
+ "function" => {
436
+ "name" => tc.name,
437
+ "arguments" => args_string
438
+ }
439
+ }
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
445
+ end
446
+ end
447
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "opentelemetry/sdk"
4
4
  require "json"
5
+ require_relative "../tokens"
5
6
 
6
7
  module Braintrust
7
8
  module Trace
@@ -17,72 +18,11 @@ module Braintrust
17
18
  span.set_attribute(attr_name, JSON.generate(obj))
18
19
  end
19
20
 
20
- # Parse usage tokens from OpenAI API response, handling nested token_details
21
- # Maps OpenAI field names to Braintrust standard names:
22
- # - input_tokens → prompt_tokens
23
- # - output_tokens → completion_tokens
24
- # - total_tokens → tokens
25
- # - *_tokens_details.* → prefix_*
26
- #
21
+ # Parse usage tokens from OpenAI API response
27
22
  # @param usage [Hash, Object] usage object from OpenAI response
28
23
  # @return [Hash<String, Integer>] metrics hash with normalized names
29
24
  def self.parse_usage_tokens(usage)
30
- metrics = {}
31
- return metrics unless usage
32
-
33
- # Convert to hash if it's an object
34
- usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
35
-
36
- usage_hash.each do |key, value|
37
- key_str = key.to_s
38
-
39
- # Handle nested *_tokens_details objects
40
- if key_str.end_with?("_tokens_details")
41
- # Convert to hash if it's an object (OpenAI gem returns objects)
42
- details_hash = value.respond_to?(:to_h) ? value.to_h : value
43
- next unless details_hash.is_a?(Hash)
44
-
45
- # Extract prefix (e.g., "prompt" from "prompt_tokens_details")
46
- prefix = key_str.sub(/_tokens_details$/, "")
47
- # Translate "input" → "prompt", "output" → "completion"
48
- prefix = translate_metric_prefix(prefix)
49
-
50
- # Process nested fields (e.g., cached_tokens, reasoning_tokens)
51
- details_hash.each do |detail_key, detail_value|
52
- next unless detail_value.is_a?(Numeric)
53
- metrics["#{prefix}_#{detail_key}"] = detail_value.to_i
54
- end
55
- elsif value.is_a?(Numeric)
56
- # Handle top-level token fields
57
- case key_str
58
- when "input_tokens"
59
- metrics["prompt_tokens"] = value.to_i
60
- when "output_tokens"
61
- metrics["completion_tokens"] = value.to_i
62
- when "total_tokens"
63
- metrics["tokens"] = value.to_i
64
- else
65
- # Keep other numeric fields as-is (future-proofing)
66
- metrics[key_str] = value.to_i
67
- end
68
- end
69
- end
70
-
71
- metrics
72
- end
73
-
74
- # Translate metric prefix to be consistent between different API formats
75
- # @param prefix [String] the prefix to translate
76
- # @return [String] translated prefix
77
- def self.translate_metric_prefix(prefix)
78
- case prefix
79
- when "input"
80
- "prompt"
81
- when "output"
82
- "completion"
83
- else
84
- prefix
85
- end
25
+ Braintrust::Trace.parse_openai_usage_tokens(usage)
86
26
  end
87
27
 
88
28
  # Aggregate streaming chunks into a single response structure
@@ -124,7 +64,7 @@ module Braintrust
124
64
  choice_data[index] ||= {
125
65
  index: index,
126
66
  role: nil,
127
- content: "",
67
+ content: +"",
128
68
  tool_calls: [],
129
69
  finish_reason: nil
130
70
  }
@@ -136,7 +76,7 @@ module Braintrust
136
76
 
137
77
  # Aggregate content
138
78
  if delta[:content]
139
- choice_data[index][:content] += delta[:content]
79
+ choice_data[index][:content] << delta[:content]
140
80
  end
141
81
 
142
82
  # Aggregate tool_calls (similar to Go SDK logic)
@@ -149,15 +89,15 @@ module Braintrust
149
89
  id: tool_call_delta[:id],
150
90
  type: tool_call_delta[:type],
151
91
  function: {
152
- name: tool_call_delta.dig(:function, :name) || "",
153
- arguments: tool_call_delta.dig(:function, :arguments) || ""
92
+ name: tool_call_delta.dig(:function, :name) || +"",
93
+ arguments: tool_call_delta.dig(:function, :arguments) || +""
154
94
  }
155
95
  }
156
96
  elsif choice_data[index][:tool_calls].any?
157
97
  # Continuation - append arguments to last tool call
158
98
  last_tool_call = choice_data[index][:tool_calls].last
159
99
  if tool_call_delta.dig(:function, :arguments)
160
- last_tool_call[:function][:arguments] += tool_call_delta[:function][:arguments]
100
+ last_tool_call[:function][:arguments] << tool_call_delta[:function][:arguments]
161
101
  end
162
102
  end
163
103
  end
@@ -353,6 +293,119 @@ module Braintrust
353
293
 
354
294
  stream
355
295
  end
296
+
297
+ # Wrap stream for streaming chat completions (returns ChatCompletionStream with convenience methods)
298
+ define_method(:stream) do |**params|
299
+ tracer = tracer_provider.tracer("braintrust")
300
+ metadata = {
301
+ "provider" => "openai",
302
+ "endpoint" => "/v1/chat/completions"
303
+ }
304
+
305
+ # Start span with proper context (will be child of current span if any)
306
+ span = tracer.start_span("openai.chat.completions.create")
307
+
308
+ # Capture request metadata fields
309
+ metadata_fields = %i[
310
+ model frequency_penalty logit_bias logprobs max_tokens n
311
+ presence_penalty response_format seed service_tier stop
312
+ stream stream_options temperature top_p top_logprobs
313
+ tools tool_choice parallel_tool_calls user functions function_call
314
+ ]
315
+
316
+ metadata_fields.each do |field|
317
+ metadata[field.to_s] = params[field] if params.key?(field)
318
+ end
319
+ metadata["stream"] = true # Explicitly mark as streaming
320
+
321
+ # Set input messages as JSON
322
+ if params[:messages]
323
+ messages_array = params[:messages].map(&:to_h)
324
+ span.set_attribute("braintrust.input_json", JSON.generate(messages_array))
325
+ end
326
+
327
+ # Set initial metadata
328
+ span.set_attribute("braintrust.metadata", JSON.generate(metadata))
329
+
330
+ # Call the original stream method with error handling
331
+ begin
332
+ stream = super(**params)
333
+ rescue => e
334
+ # Record exception if stream creation fails
335
+ span.record_exception(e)
336
+ span.status = ::OpenTelemetry::Trace::Status.error("OpenAI API error: #{e.message}")
337
+ span.finish
338
+ raise
339
+ end
340
+
341
+ # Local helper for setting JSON attributes
342
+ set_json_attr = ->(attr_name, obj) { Braintrust::Trace::OpenAI.set_json_attr(span, attr_name, obj) }
343
+
344
+ # Helper to extract metadata from SDK's internal snapshot
345
+ extract_stream_metadata = lambda do
346
+ # Access the SDK's internal accumulated completion snapshot
347
+ snapshot = stream.current_completion_snapshot
348
+ return unless snapshot
349
+
350
+ # Set output from accumulated choices
351
+ if snapshot.choices&.any?
352
+ choices_array = snapshot.choices.map(&:to_h)
353
+ set_json_attr.call("braintrust.output_json", choices_array)
354
+ end
355
+
356
+ # Set metrics if usage is available
357
+ if snapshot.usage
358
+ metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(snapshot.usage)
359
+ set_json_attr.call("braintrust.metrics", metrics) unless metrics.empty?
360
+ end
361
+
362
+ # Update metadata with response fields
363
+ metadata["id"] = snapshot.id if snapshot.respond_to?(:id) && snapshot.id
364
+ metadata["created"] = snapshot.created if snapshot.respond_to?(:created) && snapshot.created
365
+ metadata["model"] = snapshot.model if snapshot.respond_to?(:model) && snapshot.model
366
+ metadata["system_fingerprint"] = snapshot.system_fingerprint if snapshot.respond_to?(:system_fingerprint) && snapshot.system_fingerprint
367
+ set_json_attr.call("braintrust.metadata", metadata)
368
+ end
369
+
370
+ # Prevent double-finish of span
371
+ finish_braintrust_span = lambda do
372
+ return if stream.instance_variable_get(:@braintrust_span_finished)
373
+ stream.instance_variable_set(:@braintrust_span_finished, true)
374
+ extract_stream_metadata.call
375
+ span.finish
376
+ end
377
+
378
+ # Wrap .each() method - this is the core consumption method
379
+ original_each = stream.method(:each)
380
+ stream.define_singleton_method(:each) do |&block|
381
+ original_each.call(&block)
382
+ rescue => e
383
+ span.record_exception(e)
384
+ span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
385
+ raise
386
+ ensure
387
+ finish_braintrust_span.call
388
+ end
389
+
390
+ # Wrap .text() method - returns enumerable for text deltas
391
+ original_text = stream.method(:text)
392
+ stream.define_singleton_method(:text) do
393
+ text_enum = original_text.call
394
+ # Wrap the returned enumerable's .each method
395
+ text_enum.define_singleton_method(:each) do |&block|
396
+ super(&block)
397
+ rescue => e
398
+ span.record_exception(e)
399
+ span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
400
+ raise
401
+ ensure
402
+ finish_braintrust_span.call
403
+ end
404
+ text_enum
405
+ end
406
+
407
+ stream
408
+ end
356
409
  end
357
410
 
358
411
  # Prepend the wrapper to the completions resource