braintrust 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 866cb2e797502f00cda1625ad90f4d734b4b83f0d21d8243675a933fae9df693
4
- data.tar.gz: f74151b0e18b12cf19b61b1b75b2f58e784d4171f21c0996526d29c719174260
3
+ metadata.gz: ad055b60c4efb984bce955b1c00c684c760c849dea7a54d49a452f925dbab629
4
+ data.tar.gz: fe271abfac7810e53ff88efb139bc41b519799ed7dbd196ab5bb64fcbc35b62c
5
5
  SHA512:
6
- metadata.gz: ad2f68a6de8d547b6a609c3393522c4ae3dfcb441a9fc841484bbbcb21de7648da7a00cd625612d98c6b99e4ad41186a2bc3fff706e17b9797e7ac514e685923
7
- data.tar.gz: f0613e5fa08c07333c74467ec7830a40f72905475e35becf7a2add077168c7554046aa9a3824fe24006870338163526e8d170cfd25727af5d53416283ae03714
6
+ metadata.gz: a33fc58073542bf7d7dbf45092d9f3a6669d7f13fa98d3d8753d8fccd456c7e010b8a3ac0be5625e6761e164d2223f20b7fbaa3f02ead978f47398153c6c8ac2
7
+ data.tar.gz: b24f1377f4ec25f09c7c5e1366e1428c9164d7b3b7bdcf82331aa84d10603b250c61983ea811a13e7be1a1276558ef2995292fbefb20e99a374e59e9eaefb8b1
@@ -71,15 +71,59 @@ module Braintrust
71
71
  # Check if already wrapped to make this idempotent
72
72
  return chat if chat.instance_variable_get(:@braintrust_wrapped)
73
73
 
74
- # Create a wrapper module that intercepts chat.ask
74
+ # Create a wrapper module that intercepts chat.complete
75
75
  wrapper = create_wrapper_module(tracer_provider)
76
76
 
77
77
  # Mark as wrapped and prepend the wrapper to the chat instance
78
78
  chat.instance_variable_set(:@braintrust_wrapped, true)
79
79
  chat.singleton_class.prepend(wrapper)
80
+
81
+ # Register tool callbacks for tool span creation
82
+ register_tool_callbacks(chat, tracer_provider)
83
+
80
84
  chat
81
85
  end
82
86
 
87
+ # Register callbacks for tool execution tracing
88
+ # @param chat [RubyLLM::Chat] the chat instance
89
+ # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
90
+ def self.register_tool_callbacks(chat, tracer_provider)
91
+ tracer = tracer_provider.tracer("braintrust")
92
+
93
+ # Track tool spans by tool_call_id
94
+ tool_spans = {}
95
+
96
+ # Start tool span when tool is called
97
+ chat.on_tool_call do |tool_call|
98
+ span = tracer.start_span("ruby_llm.tool.#{tool_call.name}")
99
+ set_json_attr(span, "braintrust.span_attributes", {type: "tool"})
100
+ span.set_attribute("tool.name", tool_call.name)
101
+ span.set_attribute("tool.call_id", tool_call.id)
102
+
103
+ # Store tool input
104
+ input = {
105
+ "name" => tool_call.name,
106
+ "arguments" => tool_call.arguments
107
+ }
108
+ set_json_attr(span, "braintrust.input_json", input)
109
+
110
+ tool_spans[tool_call.id] = span
111
+ end
112
+
113
+ # End tool span when result is received
114
+ chat.on_tool_result do |result|
115
+ # Find the most recent tool span (RubyLLM doesn't pass tool_call_id to on_tool_result)
116
+ # The spans are processed in order, so we can use the first unfinished one
117
+ tool_call_id, span = tool_spans.find { |_id, s| s }
118
+ if span
119
+ # Store tool output
120
+ set_json_attr(span, "braintrust.output_json", result)
121
+ span.finish
122
+ tool_spans.delete(tool_call_id)
123
+ end
124
+ end
125
+ end
126
+
83
127
  # Unwrap RubyLLM to remove Braintrust tracing
84
128
  # For class-level unwrapping, removes the initialize override from the wrapper module
85
129
  # For instance-level unwrapping, clears the wrapped flag
@@ -116,50 +160,75 @@ module Braintrust
116
160
  ::RubyLLM::Chat.prepend(wrapper)
117
161
  end
118
162
 
119
- # Create the wrapper module that intercepts chat.ask
163
+ # Create the wrapper module that intercepts chat.complete
164
+ # We wrap complete() instead of ask() because:
165
+ # - ask() internally calls complete() for the actual API call
166
+ # - ActiveRecord integration (acts_as_chat) calls complete() directly
167
+ # - This ensures all LLM calls are traced regardless of entry point
168
+ #
169
+ # Important: RubyLLM's complete() calls itself recursively for tool execution.
170
+ # We only create a span for the outermost call to avoid duplicate spans.
171
+ # Tool execution is traced separately via on_tool_call/on_tool_result callbacks.
172
+ #
120
173
  # @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
121
174
  # @return [Module] the wrapper module
122
175
  def self.create_wrapper_module(tracer_provider)
123
176
  Module.new do
124
- define_method(:ask) do |prompt = nil, **params, &block|
177
+ define_method(:complete) do |&block|
178
+ # Check if we're already inside a traced complete() call
179
+ # If so, just call super without creating a new span
180
+ if @braintrust_in_complete
181
+ if block
182
+ return super(&block)
183
+ else
184
+ return super()
185
+ end
186
+ end
187
+
125
188
  tracer = tracer_provider.tracer("braintrust")
126
189
 
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)
190
+ # Mark that we're inside a complete() call
191
+ @braintrust_in_complete = true
192
+
193
+ begin
194
+ if block
195
+ # Handle streaming request
196
+ wrapped_block = proc do |chunk|
197
+ block.call(chunk)
198
+ end
199
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_streaming_complete(self, tracer, block) do |aggregated_chunks|
200
+ super(&proc do |chunk|
201
+ aggregated_chunks << chunk
202
+ wrapped_block.call(chunk)
203
+ end)
204
+ end
205
+ else
206
+ # Handle non-streaming request
207
+ Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_non_streaming_complete(self, tracer) do
208
+ super()
136
209
  end
137
210
  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
211
+ ensure
212
+ @braintrust_in_complete = false
143
213
  end
144
214
  end
145
215
  end
146
216
  end
147
217
 
148
- # Handle streaming chat request with tracing
218
+ # Handle streaming complete request with tracing
149
219
  # @param chat [RubyLLM::Chat] the chat instance
150
220
  # @param tracer [OpenTelemetry::Trace::Tracer] the tracer
151
- # @param prompt [String, nil] the user prompt
152
- # @param params [Hash] additional parameters
153
221
  # @param block [Proc] the streaming block
154
- def self.handle_streaming_ask(chat, tracer, prompt, params, block)
222
+ def self.handle_streaming_complete(chat, tracer, block)
155
223
  # Start span immediately for accurate timing
156
- span = tracer.start_span("ruby_llm.chat.ask")
224
+ span = tracer.start_span("ruby_llm.chat")
157
225
 
158
226
  aggregated_chunks = []
159
227
 
160
228
  # Extract metadata and build input messages
229
+ # For complete(), messages are already in chat history (no prompt param)
161
230
  metadata = extract_metadata(chat, stream: true)
162
- input_messages = build_input_messages(chat, prompt)
231
+ input_messages = build_input_messages(chat, nil)
163
232
 
164
233
  # Set input and metadata
165
234
  set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
@@ -181,19 +250,18 @@ module Braintrust
181
250
  result
182
251
  end
183
252
 
184
- # Handle non-streaming chat request with tracing
253
+ # Handle non-streaming complete request with tracing
185
254
  # @param chat [RubyLLM::Chat] the chat instance
186
255
  # @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)
256
+ def self.handle_non_streaming_complete(chat, tracer)
190
257
  # Start span immediately for accurate timing
191
- span = tracer.start_span("ruby_llm.chat.ask")
258
+ span = tracer.start_span("ruby_llm.chat")
192
259
 
193
260
  begin
194
261
  # Extract metadata and build input messages
262
+ # For complete(), messages are already in chat history (no prompt param)
195
263
  metadata = extract_metadata(chat)
196
- input_messages = build_input_messages(chat, prompt)
264
+ input_messages = build_input_messages(chat, nil)
197
265
  set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
198
266
 
199
267
  # Remember message count before the call (for tool call detection)
@@ -321,23 +389,62 @@ module Braintrust
321
389
  end
322
390
 
323
391
  # Build input messages array from chat history and prompt
392
+ # Formats messages to match OpenAI's message format
324
393
  # @param chat [RubyLLM::Chat] the chat instance
325
394
  # @param prompt [String, nil] the user prompt
326
395
  # @return [Array<Hash>] array of message hashes
327
396
  def self.build_input_messages(chat, prompt)
328
397
  input_messages = []
329
398
 
330
- # Add conversation history
399
+ # Add conversation history, formatting each message to OpenAI format
331
400
  if chat.respond_to?(:messages) && chat.messages&.any?
332
- input_messages = chat.messages.map { |m| m.respond_to?(:to_h) ? m.to_h : m }
401
+ input_messages = chat.messages.map { |m| format_message_for_input(m) }
333
402
  end
334
403
 
335
404
  # Add current prompt
336
- input_messages << {role: "user", content: prompt} if prompt
405
+ input_messages << {"role" => "user", "content" => prompt} if prompt
337
406
 
338
407
  input_messages
339
408
  end
340
409
 
410
+ # Format a RubyLLM message to OpenAI-compatible format
411
+ # @param msg [Object] the RubyLLM message
412
+ # @return [Hash] OpenAI-formatted message
413
+ def self.format_message_for_input(msg)
414
+ formatted = {
415
+ "role" => msg.role.to_s
416
+ }
417
+
418
+ # Handle content
419
+ if msg.respond_to?(:content) && msg.content
420
+ # Convert Ruby hash notation to JSON string for tool results
421
+ content = msg.content
422
+ if msg.role.to_s == "tool" && content.is_a?(String) && content.start_with?("{:")
423
+ # Ruby hash string like "{:location=>...}" - try to parse and re-serialize as JSON
424
+ begin
425
+ # Simple conversion: replace Ruby hash syntax with JSON
426
+ content = content.gsub(/(?<=\{|, ):(\w+)=>/, '"\1":').gsub("=>", ":")
427
+ rescue
428
+ # Keep original if conversion fails
429
+ end
430
+ end
431
+ formatted["content"] = content
432
+ end
433
+
434
+ # Handle tool_calls for assistant messages
435
+ if msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
436
+ formatted["tool_calls"] = format_tool_calls(msg.tool_calls)
437
+ formatted["content"] = nil
438
+ end
439
+
440
+ # Handle tool_call_id for tool result messages
441
+ if msg.respond_to?(:tool_call_id) && msg.tool_call_id
442
+ formatted["tool_call_id"] = msg.tool_call_id
443
+ end
444
+
445
+ formatted
446
+ end
447
+
341
448
  # Capture streaming output and metrics
342
449
  # @param span [OpenTelemetry::Trace::Span] the span
343
450
  # @param aggregated_chunks [Array] the aggregated chunks
@@ -383,8 +490,9 @@ module Braintrust
383
490
  end
384
491
 
385
492
  # Check if there are tool calls in the messages history
493
+ # Look at messages added during this complete() call
386
494
  if chat.respond_to?(:messages) && chat.messages
387
- assistant_msg = chat.messages[(messages_before_count + 1)..].find { |m|
495
+ assistant_msg = chat.messages[messages_before_count..].find { |m|
388
496
  m.role.to_s == "assistant" && m.respond_to?(:tool_calls) && m.tool_calls&.any?
389
497
  }
390
498
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: braintrust
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust