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.
- checksums.yaml +4 -4
- data/README.md +213 -180
- data/exe/braintrust +143 -0
- data/lib/braintrust/contrib/anthropic/deprecated.rb +24 -0
- data/lib/braintrust/contrib/anthropic/instrumentation/common.rb +53 -0
- data/lib/braintrust/contrib/anthropic/instrumentation/messages.rb +232 -0
- data/lib/braintrust/contrib/anthropic/integration.rb +53 -0
- data/lib/braintrust/contrib/anthropic/patcher.rb +62 -0
- data/lib/braintrust/contrib/context.rb +56 -0
- data/lib/braintrust/contrib/integration.rb +160 -0
- data/lib/braintrust/contrib/openai/deprecated.rb +22 -0
- data/lib/braintrust/contrib/openai/instrumentation/chat.rb +298 -0
- data/lib/braintrust/contrib/openai/instrumentation/common.rb +134 -0
- data/lib/braintrust/contrib/openai/instrumentation/responses.rb +187 -0
- data/lib/braintrust/contrib/openai/integration.rb +58 -0
- data/lib/braintrust/contrib/openai/patcher.rb +130 -0
- data/lib/braintrust/contrib/patcher.rb +76 -0
- data/lib/braintrust/contrib/rails/railtie.rb +16 -0
- data/lib/braintrust/contrib/registry.rb +107 -0
- data/lib/braintrust/contrib/ruby_llm/deprecated.rb +45 -0
- data/lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb +464 -0
- data/lib/braintrust/contrib/ruby_llm/instrumentation/common.rb +58 -0
- data/lib/braintrust/contrib/ruby_llm/integration.rb +54 -0
- data/lib/braintrust/contrib/ruby_llm/patcher.rb +44 -0
- data/lib/braintrust/contrib/ruby_openai/deprecated.rb +24 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/chat.rb +149 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/common.rb +138 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/responses.rb +146 -0
- data/lib/braintrust/contrib/ruby_openai/integration.rb +58 -0
- data/lib/braintrust/contrib/ruby_openai/patcher.rb +85 -0
- data/lib/braintrust/contrib/setup.rb +168 -0
- data/lib/braintrust/contrib/support/openai.rb +72 -0
- data/lib/braintrust/contrib/support/otel.rb +23 -0
- data/lib/braintrust/contrib.rb +205 -0
- data/lib/braintrust/internal/env.rb +33 -0
- data/lib/braintrust/internal/time.rb +44 -0
- data/lib/braintrust/setup.rb +50 -0
- data/lib/braintrust/state.rb +5 -0
- data/lib/braintrust/trace.rb +0 -51
- data/lib/braintrust/version.rb +1 -1
- data/lib/braintrust.rb +10 -1
- metadata +38 -7
- data/lib/braintrust/trace/contrib/anthropic.rb +0 -316
- data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +0 -377
- data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +0 -631
- data/lib/braintrust/trace/contrib/openai.rb +0 -611
- 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
|