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,631 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "opentelemetry/sdk"
4
- require "json"
5
- require_relative "../../../tokens"
6
- require_relative "../../../../logger"
7
- require_relative "../../../../internal/encoding"
8
-
9
- module Braintrust
10
- module Trace
11
- module Contrib
12
- module Github
13
- module Crmne
14
- module RubyLLM
15
- # Helper to safely set a JSON attribute on a span
16
- # Only sets the attribute if obj is present
17
- # @param span [OpenTelemetry::Trace::Span] the span to set attribute on
18
- # @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
19
- # @param obj [Object] the object to serialize to JSON
20
- # @return [void]
21
- def self.set_json_attr(span, attr_name, obj)
22
- return unless obj
23
- span.set_attribute(attr_name, JSON.generate(obj))
24
- rescue => e
25
- Log.debug("Failed to serialize #{attr_name}: #{e.message}")
26
- end
27
-
28
- # Parse usage tokens from RubyLLM response
29
- # RubyLLM uses Anthropic-style field naming (input_tokens, output_tokens)
30
- # @param usage [Hash, Object] usage object from RubyLLM response
31
- # @return [Hash<String, Integer>] metrics hash with normalized names
32
- def self.parse_usage_tokens(usage)
33
- Braintrust::Trace.parse_anthropic_usage_tokens(usage)
34
- end
35
-
36
- # Wrap RubyLLM to automatically create spans for chat requests
37
- # Supports both synchronous and streaming requests
38
- #
39
- # Usage:
40
- # # Wrap the class once (affects all future instances):
41
- # Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
42
- #
43
- # # Or wrap a specific instance:
44
- # chat = RubyLLM.chat(model: "gpt-4o-mini")
45
- # Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(chat)
46
- #
47
- # @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to wrap (if nil, wraps the class)
48
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
49
- def self.wrap(chat = nil, tracer_provider: nil)
50
- tracer_provider ||= ::OpenTelemetry.tracer_provider
51
-
52
- # If no chat instance provided, wrap the class globally via initialize hook
53
- if chat.nil?
54
- return if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
55
-
56
- # Create module that wraps initialize to auto-wrap each new instance
57
- wrapper_module = Module.new do
58
- define_method(:initialize) do |*args, **kwargs, &block|
59
- super(*args, **kwargs, &block)
60
- # Auto-wrap this instance during initialization
61
- Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(self, tracer_provider: tracer_provider)
62
- self
63
- end
64
- end
65
-
66
- # Store reference to wrapper module for cleanup
67
- ::RubyLLM::Chat.instance_variable_set(:@braintrust_wrapper_module, wrapper_module)
68
- ::RubyLLM::Chat.prepend(wrapper_module)
69
- return nil
70
- end
71
-
72
- # Check if already wrapped to make this idempotent
73
- return chat if chat.instance_variable_get(:@braintrust_wrapped)
74
-
75
- # Create a wrapper module that intercepts chat.complete
76
- wrapper = create_wrapper_module(tracer_provider)
77
-
78
- # Mark as wrapped and prepend the wrapper to the chat instance
79
- chat.instance_variable_set(:@braintrust_wrapped, true)
80
- chat.singleton_class.prepend(wrapper)
81
-
82
- # Register tool callbacks for tool span creation
83
- register_tool_callbacks(chat, tracer_provider)
84
-
85
- chat
86
- end
87
-
88
- # Register callbacks for tool execution tracing
89
- # @param chat [RubyLLM::Chat] the chat instance
90
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
91
- def self.register_tool_callbacks(chat, tracer_provider)
92
- tracer = tracer_provider.tracer("braintrust")
93
-
94
- # Track tool spans by tool_call_id
95
- tool_spans = {}
96
-
97
- # Start tool span when tool is called
98
- chat.on_tool_call do |tool_call|
99
- span = tracer.start_span("ruby_llm.tool.#{tool_call.name}")
100
- set_json_attr(span, "braintrust.span_attributes", {type: "tool"})
101
- span.set_attribute("tool.name", tool_call.name)
102
- span.set_attribute("tool.call_id", tool_call.id)
103
-
104
- # Store tool input
105
- input = {
106
- "name" => tool_call.name,
107
- "arguments" => tool_call.arguments
108
- }
109
- set_json_attr(span, "braintrust.input_json", input)
110
-
111
- tool_spans[tool_call.id] = span
112
- end
113
-
114
- # End tool span when result is received
115
- chat.on_tool_result do |result|
116
- # Find the most recent tool span (RubyLLM doesn't pass tool_call_id to on_tool_result)
117
- # The spans are processed in order, so we can use the first unfinished one
118
- tool_call_id, span = tool_spans.find { |_id, s| s }
119
- if span
120
- # Store tool output
121
- set_json_attr(span, "braintrust.output_json", result)
122
- span.finish
123
- tool_spans.delete(tool_call_id)
124
- end
125
- end
126
- end
127
-
128
- # Unwrap RubyLLM to remove Braintrust tracing
129
- # For class-level unwrapping, removes the initialize override from the wrapper module
130
- # For instance-level unwrapping, clears the wrapped flag
131
- #
132
- # @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to unwrap (if nil, unwraps the class)
133
- def self.unwrap(chat = nil)
134
- # If no chat instance provided, unwrap the class globally
135
- if chat.nil?
136
- if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
137
- wrapper_module = ::RubyLLM::Chat.instance_variable_get(:@braintrust_wrapper_module)
138
- # Redefine initialize to just call super (disables auto-wrapping)
139
- # We can't actually remove a prepended module, so we make it a no-op
140
- wrapper_module.module_eval do
141
- define_method(:initialize) do |*args, **kwargs, &block|
142
- super(*args, **kwargs, &block)
143
- end
144
- end
145
- ::RubyLLM::Chat.remove_instance_variable(:@braintrust_wrapper_module)
146
- end
147
- return nil
148
- end
149
-
150
- # Unwrap instance
151
- chat.remove_instance_variable(:@braintrust_wrapped) if chat.instance_variable_defined?(:@braintrust_wrapped)
152
- chat
153
- end
154
-
155
- # Wrap the RubyLLM::Chat class globally
156
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
157
- def self.wrap_class(tracer_provider)
158
- return unless defined?(::RubyLLM::Chat)
159
-
160
- wrapper = create_wrapper_module(tracer_provider)
161
- ::RubyLLM::Chat.prepend(wrapper)
162
- end
163
-
164
- # Create the wrapper module that intercepts chat.complete
165
- # We wrap complete() instead of ask() because:
166
- # - ask() internally calls complete() for the actual API call
167
- # - ActiveRecord integration (acts_as_chat) calls complete() directly
168
- # - This ensures all LLM calls are traced regardless of entry point
169
- #
170
- # Important: RubyLLM's complete() calls itself recursively for tool execution.
171
- # We only create a span for the outermost call to avoid duplicate spans.
172
- # Tool execution is traced separately via on_tool_call/on_tool_result callbacks.
173
- #
174
- # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
175
- # @return [Module] the wrapper module
176
- def self.create_wrapper_module(tracer_provider)
177
- Module.new do
178
- define_method(:complete) do |&block|
179
- # Check if we're already inside a traced complete() call
180
- # If so, just call super without creating a new span
181
- if @braintrust_in_complete
182
- if block
183
- return super(&block)
184
- else
185
- return super()
186
- end
187
- end
188
-
189
- tracer = tracer_provider.tracer("braintrust")
190
-
191
- # Mark that we're inside a complete() call
192
- @braintrust_in_complete = true
193
-
194
- begin
195
- if block
196
- # Handle streaming request
197
- wrapped_block = proc do |chunk|
198
- block.call(chunk)
199
- end
200
- Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_streaming_complete(self, tracer, block) do |aggregated_chunks|
201
- super(&proc do |chunk|
202
- aggregated_chunks << chunk
203
- wrapped_block.call(chunk)
204
- end)
205
- end
206
- else
207
- # Handle non-streaming request
208
- Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_non_streaming_complete(self, tracer) do
209
- super()
210
- end
211
- end
212
- ensure
213
- @braintrust_in_complete = false
214
- end
215
- end
216
- end
217
- end
218
-
219
- # Handle streaming complete request with tracing
220
- # @param chat [RubyLLM::Chat] the chat instance
221
- # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
222
- # @param block [Proc] the streaming block
223
- def self.handle_streaming_complete(chat, tracer, block)
224
- # Start span immediately for accurate timing
225
- span = tracer.start_span("ruby_llm.chat")
226
-
227
- aggregated_chunks = []
228
-
229
- # Extract metadata and build input messages
230
- # For complete(), messages are already in chat history (no prompt param)
231
- metadata = extract_metadata(chat, stream: true)
232
- input_messages = build_input_messages(chat, nil)
233
-
234
- # Set input and metadata
235
- set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
236
- set_json_attr(span, "braintrust.metadata", metadata)
237
-
238
- # Call original method, passing aggregated_chunks to the block
239
- begin
240
- result = yield aggregated_chunks
241
- rescue => e
242
- span.record_exception(e)
243
- span.status = ::OpenTelemetry::Trace::Status.error("RubyLLM error: #{e.message}")
244
- span.finish
245
- raise
246
- end
247
-
248
- # Set output and metrics from aggregated chunks
249
- capture_streaming_output(span, aggregated_chunks, result)
250
- span.finish
251
- result
252
- end
253
-
254
- # Handle non-streaming complete request with tracing
255
- # @param chat [RubyLLM::Chat] the chat instance
256
- # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
257
- def self.handle_non_streaming_complete(chat, tracer)
258
- # Start span immediately for accurate timing
259
- span = tracer.start_span("ruby_llm.chat")
260
-
261
- begin
262
- # Extract metadata and build input messages
263
- # For complete(), messages are already in chat history (no prompt param)
264
- metadata = extract_metadata(chat)
265
- input_messages = build_input_messages(chat, nil)
266
- set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
267
-
268
- # Remember message count before the call (for tool call detection)
269
- messages_before_count = (chat.respond_to?(:messages) && chat.messages) ? chat.messages.length : 0
270
-
271
- # Call the original method
272
- response = yield
273
-
274
- # Capture output and metrics
275
- capture_non_streaming_output(span, chat, response, messages_before_count)
276
-
277
- # Set metadata
278
- set_json_attr(span, "braintrust.metadata", metadata)
279
-
280
- response
281
- ensure
282
- span.finish
283
- end
284
- end
285
-
286
- # Extract metadata from chat instance (provider, model, tools, stream flag)
287
- # @param chat [RubyLLM::Chat] the chat instance
288
- # @param stream [Boolean] whether this is a streaming request
289
- # @return [Hash] metadata hash
290
- def self.extract_metadata(chat, stream: false)
291
- metadata = {"provider" => "ruby_llm"}
292
- metadata["stream"] = true if stream
293
-
294
- # Extract model
295
- if chat.respond_to?(:model) && chat.model
296
- model = chat.model.respond_to?(:id) ? chat.model.id : chat.model.to_s
297
- metadata["model"] = model
298
- end
299
-
300
- # Extract tools (only for non-streaming)
301
- if !stream && chat.respond_to?(:tools) && chat.tools&.any?
302
- metadata["tools"] = extract_tools_metadata(chat)
303
- end
304
-
305
- metadata
306
- end
307
-
308
- # Extract tools metadata from chat instance
309
- # @param chat [RubyLLM::Chat] the chat instance
310
- # @return [Array<Hash>] array of tool schemas
311
- def self.extract_tools_metadata(chat)
312
- provider = chat.instance_variable_get(:@provider) if chat.instance_variable_defined?(:@provider)
313
-
314
- chat.tools.map do |_name, tool|
315
- format_tool_schema(tool, provider)
316
- end
317
- end
318
-
319
- # Format a tool into OpenAI-compatible schema
320
- # @param tool [Object] the tool object
321
- # @param provider [Object, nil] the provider instance
322
- # @return [Hash] tool schema
323
- def self.format_tool_schema(tool, provider)
324
- tool_schema = nil
325
-
326
- # Use provider-specific tool_for method if available
327
- if provider
328
- begin
329
- tool_schema = if provider.is_a?(::RubyLLM::Providers::OpenAI)
330
- ::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
331
- elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
332
- ::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
333
- elsif tool.respond_to?(:params_schema) && tool.params_schema
334
- build_basic_tool_schema(tool)
335
- else
336
- build_minimal_tool_schema(tool)
337
- end
338
- rescue NameError, ArgumentError => e
339
- # If provider-specific tool_for fails, fall back to basic format
340
- Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
341
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
342
- end
343
- else
344
- # No provider, use basic format with params_schema if available
345
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
346
- end
347
-
348
- # Strip RubyLLM-specific fields to match native OpenAI format
349
- # Handle both symbol and string keys
350
- function_key = tool_schema&.key?(:function) ? :function : "function"
351
- if tool_schema && tool_schema[function_key]
352
- tool_params = tool_schema[function_key][:parameters] || tool_schema[function_key]["parameters"]
353
- if tool_params.is_a?(Hash)
354
- # Create a mutable copy if the hash is frozen
355
- tool_params = tool_params.dup if tool_params.frozen?
356
- tool_params.delete("strict")
357
- tool_params.delete(:strict)
358
- tool_params.delete("additionalProperties")
359
- tool_params.delete(:additionalProperties)
360
- # Assign the modified copy back
361
- params_key = tool_schema[function_key].key?(:parameters) ? :parameters : "parameters"
362
- tool_schema[function_key][params_key] = tool_params
363
- end
364
- end
365
-
366
- tool_schema
367
- end
368
-
369
- # Build a basic tool schema with parameters
370
- # @param tool [Object] the tool object
371
- # @return [Hash] tool schema
372
- def self.build_basic_tool_schema(tool)
373
- {
374
- "type" => "function",
375
- "function" => {
376
- "name" => tool.name.to_s,
377
- "description" => tool.description,
378
- "parameters" => tool.params_schema
379
- }
380
- }
381
- end
382
-
383
- # Build a minimal tool schema without parameters
384
- # @param tool [Object] the tool object
385
- # @return [Hash] tool schema
386
- def self.build_minimal_tool_schema(tool)
387
- {
388
- "type" => "function",
389
- "function" => {
390
- "name" => tool.name.to_s,
391
- "description" => tool.description,
392
- "parameters" => {}
393
- }
394
- }
395
- end
396
-
397
- # Build input messages array from chat history and prompt
398
- # Formats messages to match OpenAI's message format
399
- # @param chat [RubyLLM::Chat] the chat instance
400
- # @param prompt [String, nil] the user prompt
401
- # @return [Array<Hash>] array of message hashes
402
- def self.build_input_messages(chat, prompt)
403
- input_messages = []
404
-
405
- # Add conversation history, formatting each message to OpenAI format
406
- if chat.respond_to?(:messages) && chat.messages&.any?
407
- input_messages = chat.messages.map { |m| format_message_for_input(m) }
408
- end
409
-
410
- # Add current prompt
411
- input_messages << {"role" => "user", "content" => prompt} if prompt
412
-
413
- input_messages
414
- end
415
-
416
- # Format a RubyLLM message to OpenAI-compatible format
417
- # @param msg [Object] the RubyLLM message
418
- # @return [Hash] OpenAI-formatted message
419
- def self.format_message_for_input(msg)
420
- formatted = {
421
- "role" => msg.role.to_s
422
- }
423
-
424
- # Handle content
425
- if msg.respond_to?(:content) && msg.content
426
- raw_content = msg.content
427
-
428
- # Check if content is a Content object with attachments (issue #71)
429
- formatted["content"] = if raw_content.respond_to?(:text) && raw_content.respond_to?(:attachments) && raw_content.attachments&.any?
430
- format_multipart_content(raw_content)
431
- else
432
- format_simple_content(raw_content, msg.role.to_s)
433
- end
434
- end
435
-
436
- # Handle tool_calls for assistant messages
437
- if msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
438
- formatted["tool_calls"] = format_tool_calls(msg.tool_calls)
439
- formatted["content"] = nil
440
- end
441
-
442
- # Handle tool_call_id for tool result messages
443
- if msg.respond_to?(:tool_call_id) && msg.tool_call_id
444
- formatted["tool_call_id"] = msg.tool_call_id
445
- end
446
-
447
- formatted
448
- end
449
-
450
- # Format multipart content with text and attachments
451
- # @param content_obj [Object] Content object with text and attachments
452
- # @return [Array<Hash>] array of content parts
453
- def self.format_multipart_content(content_obj)
454
- content_parts = []
455
-
456
- # Add text part
457
- content_parts << {"type" => "text", "text" => content_obj.text} if content_obj.text
458
-
459
- # Add attachment parts (convert to Braintrust format)
460
- content_obj.attachments.each do |attachment|
461
- content_parts << format_attachment_for_input(attachment)
462
- end
463
-
464
- content_parts
465
- end
466
-
467
- # Format simple text content
468
- # @param raw_content [Object] String or Content object with text
469
- # @param role [String] the message role
470
- # @return [String] formatted text content
471
- def self.format_simple_content(raw_content, role)
472
- content = raw_content
473
- content = content.text if content.respond_to?(:text)
474
-
475
- # Convert Ruby hash string to JSON for tool results
476
- if role == "tool" && content.is_a?(String) && content.start_with?("{:")
477
- begin
478
- content = content.gsub(/(?<=\{|, ):(\w+)=>/, '"\1":').gsub("=>", ":")
479
- rescue
480
- # Keep original if conversion fails
481
- end
482
- end
483
-
484
- content
485
- end
486
-
487
- # Format a RubyLLM attachment to OpenAI-compatible format
488
- # @param attachment [Object] the RubyLLM attachment
489
- # @return [Hash] OpenAI image_url format for consistency with other integrations
490
- def self.format_attachment_for_input(attachment)
491
- # RubyLLM Attachment has: source (Pathname), filename, mime_type
492
- if attachment.respond_to?(:source) && attachment.source
493
- begin
494
- data = File.binread(attachment.source.to_s)
495
- encoded = Internal::Encoding::Base64.strict_encode64(data)
496
- mime_type = attachment.respond_to?(:mime_type) ? attachment.mime_type : "application/octet-stream"
497
-
498
- # Use OpenAI's image_url format for consistency
499
- {
500
- "type" => "image_url",
501
- "image_url" => {
502
- "url" => "data:#{mime_type};base64,#{encoded}"
503
- }
504
- }
505
- rescue => e
506
- Log.debug("Failed to read attachment file: #{e.message}")
507
- # Return a placeholder if we can't read the file
508
- {"type" => "text", "text" => "[attachment: #{attachment.respond_to?(:filename) ? attachment.filename : "unknown"}]"}
509
- end
510
- elsif attachment.respond_to?(:to_h)
511
- # Try to use attachment's own serialization
512
- attachment.to_h
513
- else
514
- {"type" => "text", "text" => "[attachment]"}
515
- end
516
- end
517
-
518
- # Capture streaming output and metrics
519
- # @param span [OpenTelemetry::Trace::Span] the span
520
- # @param aggregated_chunks [Array] the aggregated chunks
521
- # @param result [Object] the result object
522
- def self.capture_streaming_output(span, aggregated_chunks, result)
523
- return if aggregated_chunks.empty?
524
-
525
- # Aggregate content from chunks
526
- # Extract text from Content objects if present (issue #71)
527
- aggregated_content = aggregated_chunks.map { |c|
528
- content = c.respond_to?(:content) ? c.content : c.to_s
529
- content = content.text if content.respond_to?(:text)
530
- content
531
- }.join
532
-
533
- output = [{
534
- role: "assistant",
535
- content: aggregated_content
536
- }]
537
- set_json_attr(span, "braintrust.output_json", output)
538
-
539
- # Try to extract usage from the result
540
- if result.respond_to?(:usage) && result.usage
541
- metrics = parse_usage_tokens(result.usage)
542
- set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
543
- end
544
- end
545
-
546
- # Capture non-streaming output and metrics
547
- # @param span [OpenTelemetry::Trace::Span] the span
548
- # @param chat [RubyLLM::Chat] the chat instance
549
- # @param response [Object] the response object
550
- # @param messages_before_count [Integer] message count before the call
551
- def self.capture_non_streaming_output(span, chat, response, messages_before_count)
552
- return unless response
553
-
554
- # Build message object from response
555
- message = {
556
- "role" => "assistant",
557
- "content" => nil
558
- }
559
-
560
- # Add content if it's a simple text response
561
- # Extract text from Content objects if present (issue #71)
562
- if response.respond_to?(:content) && response.content && !response.content.empty?
563
- content = response.content
564
- content = content.text if content.respond_to?(:text)
565
- message["content"] = content
566
- end
567
-
568
- # Check if there are tool calls in the messages history
569
- # Look at messages added during this complete() call
570
- if chat.respond_to?(:messages) && chat.messages
571
- assistant_msg = chat.messages[messages_before_count..].find { |m|
572
- m.role.to_s == "assistant" && m.respond_to?(:tool_calls) && m.tool_calls&.any?
573
- }
574
-
575
- if assistant_msg&.tool_calls&.any?
576
- message["tool_calls"] = format_tool_calls(assistant_msg.tool_calls)
577
- message["content"] = nil
578
- end
579
- end
580
-
581
- # Format as OpenAI choices[] structure
582
- output = [{
583
- "index" => 0,
584
- "message" => message,
585
- "finish_reason" => message["tool_calls"] ? "tool_calls" : "stop"
586
- }]
587
-
588
- set_json_attr(span, "braintrust.output_json", output)
589
-
590
- # Set metrics (token usage)
591
- if response.respond_to?(:to_h)
592
- response_hash = response.to_h
593
- usage = {
594
- "input_tokens" => response_hash[:input_tokens],
595
- "output_tokens" => response_hash[:output_tokens],
596
- "cached_tokens" => response_hash[:cached_tokens],
597
- "cache_creation_tokens" => response_hash[:cache_creation_tokens]
598
- }.compact
599
-
600
- unless usage.empty?
601
- metrics = parse_usage_tokens(usage)
602
- set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
603
- end
604
- end
605
- end
606
-
607
- # Format tool calls into OpenAI format
608
- # @param tool_calls [Hash, Array] the tool calls
609
- # @return [Array<Hash>] formatted tool calls
610
- def self.format_tool_calls(tool_calls)
611
- tool_calls.map do |_id, tc|
612
- # Ensure arguments is a JSON string (OpenAI format)
613
- args = tc.arguments
614
- args_string = args.is_a?(String) ? args : JSON.generate(args)
615
-
616
- {
617
- "id" => tc.id,
618
- "type" => "function",
619
- "function" => {
620
- "name" => tc.name,
621
- "arguments" => args_string
622
- }
623
- }
624
- end
625
- end
626
- end
627
- end
628
- end
629
- end
630
- end
631
- end