braintrust 0.4.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1afc7372056311aec7ccaaae11c5d7a9204cdfc826a34db1bd67126769d253e3
4
- data.tar.gz: 6492ea96b8f616c05241983baaeb935cdc3d206ced063781c44981b6d0817e64
3
+ metadata.gz: 38aa89e0aba8b3df517d42a95d016cb09cbfbaeac91d16d4caed00f0779415eb
4
+ data.tar.gz: fd800de8e09a8ac7ca93342d65abd6c19dcdc5bffe2421dd5cf921e1b6b06645
5
5
  SHA512:
6
- metadata.gz: e6471fc6f3b8beba508dfef0deebfa5f10b1044aaf44817cfb341a71396655ec034b9eca70aaf2e4efb0afa9007a5c284564b3d3471ba1dd18edfd336ea51cb8
7
- data.tar.gz: 333ad63d354f902c5e4c5d25d2d67e5ed4f4843fcbfeb7c33a6f273de9dcd7b1c77202417816dd9b834881a3d3a833c68418e81c9a1365ec2e503da3ada027ac
6
+ metadata.gz: 82c01479c75cbb51bc79a67af1b91c46af0fdefc2895c05a54837fb38a2bd501a5e462e1cff953fc83e189ad9e1da891e39814077bb0cd03fd66970eccb285a9
7
+ data.tar.gz: '0757861a2df4962b7cdcff628c5638fe361e33b78ba76417dd3c1087c557fffc7920a857a3916f95bc65e3d6f8bb676afb848a27a6fef28dc987b0d7e237c922'
@@ -0,0 +1,19 @@
1
+ require "braintrust/contrib/rails/server"
2
+
3
+ Braintrust::Contrib::Rails::Server::Engine.configure do |config|
4
+ config.evaluators = {
5
+ <% if @evaluators.empty? -%>
6
+ # Add evaluator instances here, for example:
7
+ # "food-classifier" => FoodClassifier.new
8
+ <% else -%>
9
+ <% @evaluators.each_with_index do |evaluator, index| -%>
10
+ "<%= evaluator[:slug] %>" => <%= evaluator[:class_name] %>.new<%= "," unless index == @evaluators.length - 1 %>
11
+ <% end -%>
12
+ <% end -%>
13
+ }
14
+
15
+ # Default is :clerk_token. Use :none only when developing locally without
16
+ # incoming request authentication; outgoing Braintrust API calls still need
17
+ # a valid Braintrust API key.
18
+ config.auth = :clerk_token
19
+ end
@@ -186,17 +186,17 @@ module Braintrust
186
186
  ::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
187
187
  elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
188
188
  ::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
189
- elsif tool.respond_to?(:params_schema) && tool.params_schema
189
+ elsif tool_params_schema(tool)
190
190
  build_basic_tool_schema(tool)
191
191
  else
192
192
  build_minimal_tool_schema(tool)
193
193
  end
194
194
  rescue NameError, ArgumentError => e
195
195
  Braintrust::Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
196
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
196
+ tool_schema = tool_params_schema(tool) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
197
197
  end
198
198
  else
199
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
199
+ tool_schema = tool_params_schema(tool) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
200
200
  end
201
201
 
202
202
  # Strip RubyLLM-specific fields to match native OpenAI format
@@ -217,6 +217,19 @@ module Braintrust
217
217
  tool_schema
218
218
  end
219
219
 
220
+ # A tool's JSON Schema, across ruby_llm versions.
221
+ # 1.x exposes it as params_schema; 2.0 renamed it to parameters_schema.
222
+ # @param tool [Object] the RubyLLM tool
223
+ # @return [Hash, nil] the schema, or nil when the tool declares none
224
+ def tool_params_schema(tool)
225
+ %i[params_schema parameters_schema].each do |name|
226
+ next unless tool.respond_to?(name)
227
+ schema = tool.public_send(name)
228
+ return schema if schema
229
+ end
230
+ nil
231
+ end
232
+
220
233
  # Build a basic tool schema with parameters
221
234
  def build_basic_tool_schema(tool)
222
235
  {
@@ -224,7 +237,7 @@ module Braintrust
224
237
  "function" => {
225
238
  "name" => tool.name.to_s,
226
239
  "description" => tool.description,
227
- "parameters" => tool.params_schema
240
+ "parameters" => tool_params_schema(tool)
228
241
  }
229
242
  }
230
243
  end
@@ -255,10 +268,11 @@ module Braintrust
255
268
  # Handle content
256
269
  if msg.respond_to?(:content) && msg.content
257
270
  raw_content = msg.content
271
+ attachments = extract_attachments(msg)
258
272
 
259
- # Check if content is a Content object with attachments (issue #71)
260
- formatted["content"] = if raw_content.respond_to?(:text) && raw_content.respond_to?(:attachments) && raw_content.attachments&.any?
261
- format_multipart_content(raw_content)
273
+ # Include attachments alongside the text when present (issue #71)
274
+ formatted["content"] = if attachments.any?
275
+ format_multipart_content(content_text(raw_content), attachments)
262
276
  else
263
277
  format_simple_content(raw_content, msg.role.to_s)
264
278
  end
@@ -278,17 +292,43 @@ module Braintrust
278
292
  formatted
279
293
  end
280
294
 
295
+ # Collect a message's attachments.
296
+ #
297
+ # ruby_llm 1.x wraps text and attachments in a Content object hanging off
298
+ # the message; 2.0 removed Content, leaving content a plain String and
299
+ # exposing attachments on the message itself. Support both.
300
+ #
301
+ # @param msg [Object] the RubyLLM message
302
+ # @return [Array] the message's attachments, empty when there are none
303
+ def extract_attachments(msg)
304
+ content = msg.content if msg.respond_to?(:content)
305
+ if content.respond_to?(:attachments)
306
+ from_content = Array(content.attachments)
307
+ return from_content if from_content.any?
308
+ end
309
+
310
+ msg.respond_to?(:attachments) ? Array(msg.attachments) : []
311
+ end
312
+
313
+ # Extract the plain text of a message's content.
314
+ # @param raw_content [Object] String, or a 1.x Content object
315
+ # @return [Object] the text
316
+ def content_text(raw_content)
317
+ raw_content.respond_to?(:text) ? raw_content.text : raw_content
318
+ end
319
+
281
320
  # Format multipart content with text and attachments
282
- # @param content_obj [Object] Content object with text and attachments
321
+ # @param text [Object] the message text
322
+ # @param attachments [Array] the message's attachments
283
323
  # @return [Array<Hash>] array of content parts
284
- def format_multipart_content(content_obj)
324
+ def format_multipart_content(text, attachments)
285
325
  content_parts = []
286
326
 
287
327
  # Add text part
288
- content_parts << {"type" => "text", "text" => content_obj.text} if content_obj.text
328
+ content_parts << {"type" => "text", "text" => text} if text
289
329
 
290
330
  # Add attachment parts (convert to Braintrust format)
291
- content_obj.attachments.each do |attachment|
331
+ attachments.each do |attachment|
292
332
  content_parts << format_attachment_for_input(attachment)
293
333
  end
294
334
 
@@ -27,6 +27,11 @@ module Braintrust
27
27
  @eval_context = eval_context
28
28
  @tracer = eval_context.tracer_provider.tracer("braintrust-eval")
29
29
 
30
+ # Whether any scorer/classifier can receive `trace:`. Computed once: the
31
+ # per-case flush that makes traces queryable over BTQL is a real network
32
+ # wait, so it's only worth paying when something will actually consume it.
33
+ @needs_trace = (eval_context.scorers + eval_context.classifiers).any? { |c| wants_trace?(c) }
34
+
30
35
  # Mutexes for thread-safe result collection
31
36
  @score_mutex = Mutex.new
32
37
  @classification_mutex = Mutex.new
@@ -55,6 +60,11 @@ module Braintrust
55
60
  # Convert Queue to Array after all threads complete
56
61
  error_array = [].tap { |a| a << errors.pop until errors.empty? }
57
62
 
63
+ # Deliver any spans still buffered. Callers that supply their own
64
+ # tracer_provider get no at_exit hook (see Trace.setup), so without this
65
+ # the tail of the run would wait on the processor's schedule delay.
66
+ flush_spans
67
+
58
68
  # Calculate duration
59
69
  duration = Time.now - start_time
60
70
 
@@ -108,9 +118,11 @@ module Braintrust
108
118
  next
109
119
  end
110
120
 
111
- # Flush spans so they're queryable via BTQL, then build trace
112
- eval_context.tracer_provider.force_flush if eval_context.tracer_provider.respond_to?(:force_flush)
113
- kase.trace = build_trace(eval_span)
121
+ # Build the trace, then flush spans so they're queryable via BTQL.
122
+ # Both are skipped unless a scorer/classifier declared `trace:` and the
123
+ # trace is actually resolvable (build_trace returns nil in local-only mode).
124
+ kase.trace = build_trace(eval_span) if @needs_trace
125
+ flush_spans if kase.trace
114
126
 
115
127
  # Run scorers
116
128
  begin
@@ -229,6 +241,23 @@ module Braintrust
229
241
  end
230
242
  end
231
243
 
244
+ # Whether a callable can receive `trace:`: it either declares the keyword
245
+ # or accepts arbitrary kwargs and may forward it. Reuses the #call_parameters
246
+ # introspection Internal::Callable::KeywordFilter already depends on.
247
+ # @param callable [Scorer, Classifier]
248
+ # @return [Boolean]
249
+ def wants_trace?(callable)
250
+ return true unless callable.respond_to?(:call_parameters)
251
+
252
+ callable.call_parameters.any? { |type, name| name == :trace || type == :keyrest }
253
+ end
254
+
255
+ # Force the tracer provider to export buffered spans, if it supports it.
256
+ def flush_spans
257
+ provider = eval_context.tracer_provider
258
+ provider.force_flush if provider.respond_to?(:force_flush)
259
+ end
260
+
232
261
  # Build a lazy Trace for a case, backed by BTQL.
233
262
  # Returns nil when state or experiment_id are unavailable (local-only mode).
234
263
  # @param eval_span [OpenTelemetry::Trace::Span] The eval span for this case
@@ -152,10 +152,10 @@ module Braintrust
152
152
  case block.arity
153
153
  when 3
154
154
  Log.warn_once(:scorer_positional_3, "Scorer with positional params (input, expected, output) is deprecated. Use keyword args: |input:, expected:, output:| instead.")
155
- ->(**kw) { block.call(kw[:input], kw[:expected], kw[:output]) }
155
+ ->(input: nil, expected: nil, output: nil) { block.call(input, expected, output) }
156
156
  when 4, -4, -1
157
157
  Log.warn_once(:scorer_positional_4, "Scorer with positional params (input, expected, output, metadata) is deprecated. Use keyword args: |input:, expected:, output:, metadata:| instead.")
158
- ->(**kw) { block.call(kw[:input], kw[:expected], kw[:output], kw[:metadata]) }
158
+ ->(input: nil, expected: nil, output: nil, metadata: nil) { block.call(input, expected, output, metadata) }
159
159
  else
160
160
  raise ArgumentError, "Scorer must accept keyword args or 3-4 positional params (got arity #{block.arity})"
161
161
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
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.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -118,6 +118,7 @@ files:
118
118
  - lib/braintrust/contrib/rails/server/health_controller.rb
119
119
  - lib/braintrust/contrib/rails/server/list_controller.rb
120
120
  - lib/braintrust/contrib/rails/server/routes.rb
121
+ - lib/braintrust/contrib/rails/server/templates/initializer.rb.tt
121
122
  - lib/braintrust/contrib/registry.rb
122
123
  - lib/braintrust/contrib/ruby_llm/deprecated.rb
123
124
  - lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb