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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "common"
|
|
7
|
+
require_relative "../../../internal/time"
|
|
8
|
+
require_relative "../../support/otel"
|
|
9
|
+
require_relative "../../support/openai"
|
|
10
|
+
|
|
11
|
+
module Braintrust
|
|
12
|
+
module Contrib
|
|
13
|
+
module OpenAI
|
|
14
|
+
module Instrumentation
|
|
15
|
+
# Chat completions instrumentation for OpenAI.
|
|
16
|
+
# Wraps create(), stream(), and stream_raw() methods to create spans.
|
|
17
|
+
module Chat
|
|
18
|
+
module Completions
|
|
19
|
+
def self.included(base)
|
|
20
|
+
base.prepend(InstanceMethods) unless applied?(base)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.applied?(base)
|
|
24
|
+
base.ancestors.include?(InstanceMethods)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
METADATA_FIELDS = %i[
|
|
28
|
+
model frequency_penalty logit_bias logprobs max_tokens n
|
|
29
|
+
presence_penalty response_format seed service_tier stop
|
|
30
|
+
stream stream_options temperature top_p top_logprobs
|
|
31
|
+
tools tool_choice parallel_tool_calls user functions function_call
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
module InstanceMethods
|
|
35
|
+
# Wrap create method for non-streaming completions
|
|
36
|
+
def create(**params)
|
|
37
|
+
client = instance_variable_get(:@client)
|
|
38
|
+
tracer = Braintrust::Contrib.tracer_for(client)
|
|
39
|
+
|
|
40
|
+
tracer.in_span("Chat Completion") do |span|
|
|
41
|
+
metadata = build_metadata(params)
|
|
42
|
+
|
|
43
|
+
set_input(span, params)
|
|
44
|
+
|
|
45
|
+
response = nil
|
|
46
|
+
time_to_first_token = Braintrust::Internal::Time.measure do
|
|
47
|
+
response = super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
set_output(span, response)
|
|
51
|
+
set_metrics(span, response, time_to_first_token)
|
|
52
|
+
finalize_metadata(span, metadata, response)
|
|
53
|
+
|
|
54
|
+
response
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Wrap stream_raw for streaming chat completions (returns Internal::Stream)
|
|
59
|
+
# Stores context on stream object for span creation during consumption
|
|
60
|
+
def stream_raw(**params)
|
|
61
|
+
client = instance_variable_get(:@client)
|
|
62
|
+
tracer = Braintrust::Contrib.tracer_for(client)
|
|
63
|
+
metadata = build_metadata(params, stream: true)
|
|
64
|
+
|
|
65
|
+
stream_obj = super
|
|
66
|
+
Braintrust::Contrib::Context.set!(stream_obj,
|
|
67
|
+
tracer: tracer,
|
|
68
|
+
params: params,
|
|
69
|
+
metadata: metadata,
|
|
70
|
+
completions_instance: self,
|
|
71
|
+
stream_type: :raw)
|
|
72
|
+
stream_obj
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Wrap stream for streaming chat completions (returns ChatCompletionStream)
|
|
76
|
+
# Stores context on stream object for span creation during consumption
|
|
77
|
+
def stream(**params)
|
|
78
|
+
client = instance_variable_get(:@client)
|
|
79
|
+
tracer = Braintrust::Contrib.tracer_for(client)
|
|
80
|
+
metadata = build_metadata(params, stream: true)
|
|
81
|
+
|
|
82
|
+
stream_obj = super
|
|
83
|
+
Braintrust::Contrib::Context.set!(stream_obj,
|
|
84
|
+
tracer: tracer,
|
|
85
|
+
params: params,
|
|
86
|
+
metadata: metadata,
|
|
87
|
+
completions_instance: self,
|
|
88
|
+
stream_type: :chat_completion)
|
|
89
|
+
stream_obj
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_metadata(params, stream: false)
|
|
95
|
+
metadata = {
|
|
96
|
+
"provider" => "openai",
|
|
97
|
+
"endpoint" => "/v1/chat/completions"
|
|
98
|
+
}
|
|
99
|
+
metadata["stream"] = true if stream
|
|
100
|
+
Completions::METADATA_FIELDS.each do |field|
|
|
101
|
+
metadata[field.to_s] = params[field] if params.key?(field)
|
|
102
|
+
end
|
|
103
|
+
metadata
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def set_input(span, params)
|
|
107
|
+
return unless params[:messages]
|
|
108
|
+
|
|
109
|
+
messages_array = params[:messages].map(&:to_h)
|
|
110
|
+
Support::OTel.set_json_attr(span, "braintrust.input_json", messages_array)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def set_output(span, response)
|
|
114
|
+
return unless response.respond_to?(:choices) && response.choices&.any?
|
|
115
|
+
|
|
116
|
+
choices_array = response.choices.map(&:to_h)
|
|
117
|
+
Support::OTel.set_json_attr(span, "braintrust.output_json", choices_array)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def set_metrics(span, response, time_to_first_token)
|
|
121
|
+
metrics = {}
|
|
122
|
+
if response.respond_to?(:usage) && response.usage
|
|
123
|
+
metrics = Support::OpenAI.parse_usage_tokens(response.usage)
|
|
124
|
+
end
|
|
125
|
+
metrics["time_to_first_token"] = time_to_first_token
|
|
126
|
+
Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def finalize_metadata(span, metadata, response)
|
|
130
|
+
metadata["id"] = response.id if response.respond_to?(:id) && response.id
|
|
131
|
+
metadata["created"] = response.created if response.respond_to?(:created) && response.created
|
|
132
|
+
metadata["model"] = response.model if response.respond_to?(:model) && response.model
|
|
133
|
+
metadata["system_fingerprint"] = response.system_fingerprint if response.respond_to?(:system_fingerprint) && response.system_fingerprint
|
|
134
|
+
metadata["service_tier"] = response.service_tier if response.respond_to?(:service_tier) && response.service_tier
|
|
135
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Instrumentation for ChatCompletionStream (returned by stream())
|
|
141
|
+
# Uses current_completion_snapshot for accumulated output
|
|
142
|
+
module ChatCompletionStream
|
|
143
|
+
def self.included(base)
|
|
144
|
+
base.prepend(InstanceMethods) unless applied?(base)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.applied?(base)
|
|
148
|
+
base.ancestors.include?(InstanceMethods)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
module InstanceMethods
|
|
152
|
+
def each(&block)
|
|
153
|
+
ctx = Braintrust::Contrib::Context.from(self)
|
|
154
|
+
return super unless ctx&.[](:tracer) && !ctx[:consumed]
|
|
155
|
+
|
|
156
|
+
trace_consumption(ctx) { super(&block) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def text
|
|
160
|
+
ctx = Braintrust::Contrib::Context.from(self)
|
|
161
|
+
return super unless ctx&.[](:tracer) && !ctx[:consumed]
|
|
162
|
+
|
|
163
|
+
original_enum = super
|
|
164
|
+
Enumerator.new do |y|
|
|
165
|
+
trace_consumption(ctx) do
|
|
166
|
+
original_enum.each { |t| y << t }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def trace_consumption(ctx)
|
|
174
|
+
ctx[:consumed] = true
|
|
175
|
+
|
|
176
|
+
tracer = ctx[:tracer]
|
|
177
|
+
params = ctx[:params]
|
|
178
|
+
metadata = ctx[:metadata]
|
|
179
|
+
completions_instance = ctx[:completions_instance]
|
|
180
|
+
start_time = Braintrust::Internal::Time.measure
|
|
181
|
+
|
|
182
|
+
tracer.in_span("Chat Completion") do |span|
|
|
183
|
+
completions_instance.send(:set_input, span, params)
|
|
184
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
185
|
+
|
|
186
|
+
yield
|
|
187
|
+
|
|
188
|
+
finalize_stream_span(span, start_time, metadata, completions_instance)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def finalize_stream_span(span, start_time, metadata, completions_instance)
|
|
193
|
+
time_to_first_token = Braintrust::Internal::Time.measure(start_time)
|
|
194
|
+
|
|
195
|
+
begin
|
|
196
|
+
snapshot = current_completion_snapshot
|
|
197
|
+
return unless snapshot
|
|
198
|
+
|
|
199
|
+
# Set output from accumulated choices
|
|
200
|
+
if snapshot.choices&.any?
|
|
201
|
+
choices_array = snapshot.choices.map(&:to_h)
|
|
202
|
+
Support::OTel.set_json_attr(span, "braintrust.output_json", choices_array)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Set metrics
|
|
206
|
+
metrics = {}
|
|
207
|
+
if snapshot.usage
|
|
208
|
+
metrics = Support::OpenAI.parse_usage_tokens(snapshot.usage)
|
|
209
|
+
end
|
|
210
|
+
metrics["time_to_first_token"] = time_to_first_token
|
|
211
|
+
Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
212
|
+
|
|
213
|
+
# Update metadata with response fields
|
|
214
|
+
metadata["id"] = snapshot.id if snapshot.respond_to?(:id) && snapshot.id
|
|
215
|
+
metadata["created"] = snapshot.created if snapshot.respond_to?(:created) && snapshot.created
|
|
216
|
+
metadata["model"] = snapshot.model if snapshot.respond_to?(:model) && snapshot.model
|
|
217
|
+
metadata["system_fingerprint"] = snapshot.system_fingerprint if snapshot.respond_to?(:system_fingerprint) && snapshot.system_fingerprint
|
|
218
|
+
metadata["service_tier"] = snapshot.service_tier if snapshot.respond_to?(:service_tier) && snapshot.service_tier
|
|
219
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
220
|
+
rescue => e
|
|
221
|
+
Braintrust::Log.debug("Failed to get completion snapshot: #{e.message}")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Instrumentation for Internal::Stream (returned by stream_raw())
|
|
228
|
+
# Aggregates chunks manually since Internal::Stream has no built-in accumulation
|
|
229
|
+
module InternalStream
|
|
230
|
+
def self.included(base)
|
|
231
|
+
base.prepend(InstanceMethods) unless applied?(base)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.applied?(base)
|
|
235
|
+
base.ancestors.include?(InstanceMethods)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
module InstanceMethods
|
|
239
|
+
def each(&block)
|
|
240
|
+
ctx = Braintrust::Contrib::Context.from(self)
|
|
241
|
+
# Only trace if context present and is for chat completions (not other endpoints)
|
|
242
|
+
return super unless ctx&.[](:tracer) && !ctx[:consumed] && ctx[:stream_type] == :raw
|
|
243
|
+
|
|
244
|
+
ctx[:consumed] = true
|
|
245
|
+
|
|
246
|
+
tracer = ctx[:tracer]
|
|
247
|
+
params = ctx[:params]
|
|
248
|
+
metadata = ctx[:metadata]
|
|
249
|
+
completions_instance = ctx[:completions_instance]
|
|
250
|
+
aggregated_chunks = []
|
|
251
|
+
start_time = Braintrust::Internal::Time.measure
|
|
252
|
+
time_to_first_token = nil
|
|
253
|
+
|
|
254
|
+
tracer.in_span("Chat Completion") do |span|
|
|
255
|
+
completions_instance.send(:set_input, span, params)
|
|
256
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
257
|
+
|
|
258
|
+
super do |chunk|
|
|
259
|
+
time_to_first_token ||= Braintrust::Internal::Time.measure(start_time)
|
|
260
|
+
aggregated_chunks << chunk.to_h
|
|
261
|
+
block&.call(chunk)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
finalize_stream_span(span, aggregated_chunks, time_to_first_token, metadata)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def finalize_stream_span(span, aggregated_chunks, time_to_first_token, metadata)
|
|
271
|
+
return if aggregated_chunks.empty?
|
|
272
|
+
|
|
273
|
+
aggregated_output = Common.aggregate_streaming_chunks(aggregated_chunks)
|
|
274
|
+
Support::OTel.set_json_attr(span, "braintrust.output_json", aggregated_output[:choices])
|
|
275
|
+
|
|
276
|
+
# Set metrics
|
|
277
|
+
metrics = {}
|
|
278
|
+
if aggregated_output[:usage]
|
|
279
|
+
metrics = Support::OpenAI.parse_usage_tokens(aggregated_output[:usage])
|
|
280
|
+
end
|
|
281
|
+
metrics["time_to_first_token"] = time_to_first_token
|
|
282
|
+
Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
283
|
+
|
|
284
|
+
# Update metadata with response fields
|
|
285
|
+
metadata["id"] = aggregated_output[:id] if aggregated_output[:id]
|
|
286
|
+
metadata["created"] = aggregated_output[:created] if aggregated_output[:created]
|
|
287
|
+
metadata["model"] = aggregated_output[:model] if aggregated_output[:model]
|
|
288
|
+
metadata["system_fingerprint"] = aggregated_output[:system_fingerprint] if aggregated_output[:system_fingerprint]
|
|
289
|
+
metadata["service_tier"] = aggregated_output[:service_tier] if aggregated_output[:service_tier]
|
|
290
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Braintrust
|
|
4
|
+
module Contrib
|
|
5
|
+
module OpenAI
|
|
6
|
+
module Instrumentation
|
|
7
|
+
# Aggregation utilities for official OpenAI SDK instrumentation.
|
|
8
|
+
# These are specific to the official openai gem's data structures (symbol keys, SDK objects).
|
|
9
|
+
module Common
|
|
10
|
+
# Aggregate streaming chunks into a single response structure.
|
|
11
|
+
# Specific to official OpenAI SDK which uses symbol keys and SDK objects.
|
|
12
|
+
# @param chunks [Array<Hash>] array of chunk hashes from stream (symbol keys)
|
|
13
|
+
# @return [Hash] aggregated response with choices, usage, etc. (symbol keys)
|
|
14
|
+
def self.aggregate_streaming_chunks(chunks)
|
|
15
|
+
return {} if chunks.empty?
|
|
16
|
+
|
|
17
|
+
# Initialize aggregated structure
|
|
18
|
+
aggregated = {
|
|
19
|
+
id: nil,
|
|
20
|
+
created: nil,
|
|
21
|
+
model: nil,
|
|
22
|
+
system_fingerprint: nil,
|
|
23
|
+
choices: [],
|
|
24
|
+
usage: nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Track aggregated content and tool_calls for each choice index
|
|
28
|
+
choice_data = {}
|
|
29
|
+
|
|
30
|
+
chunks.each do |chunk|
|
|
31
|
+
# Capture top-level fields from any chunk that has them
|
|
32
|
+
aggregated[:id] ||= chunk[:id]
|
|
33
|
+
aggregated[:created] ||= chunk[:created]
|
|
34
|
+
aggregated[:model] ||= chunk[:model]
|
|
35
|
+
aggregated[:system_fingerprint] ||= chunk[:system_fingerprint]
|
|
36
|
+
|
|
37
|
+
# Aggregate usage (usually only in last chunk if stream_options.include_usage is set)
|
|
38
|
+
aggregated[:usage] = chunk[:usage] if chunk[:usage]
|
|
39
|
+
|
|
40
|
+
# Process choices
|
|
41
|
+
next unless chunk[:choices].is_a?(Array)
|
|
42
|
+
chunk[:choices].each do |choice|
|
|
43
|
+
index = choice[:index] || 0
|
|
44
|
+
choice_data[index] ||= {
|
|
45
|
+
index: index,
|
|
46
|
+
role: nil,
|
|
47
|
+
content: +"",
|
|
48
|
+
tool_calls: [],
|
|
49
|
+
finish_reason: nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
delta = choice[:delta] || {}
|
|
53
|
+
|
|
54
|
+
# Aggregate role (set once from first delta that has it)
|
|
55
|
+
choice_data[index][:role] ||= delta[:role]
|
|
56
|
+
|
|
57
|
+
# Aggregate content
|
|
58
|
+
choice_data[index][:content] << delta[:content] if delta[:content]
|
|
59
|
+
|
|
60
|
+
# Aggregate tool_calls
|
|
61
|
+
if delta[:tool_calls].is_a?(Array) && delta[:tool_calls].any?
|
|
62
|
+
delta[:tool_calls].each do |tool_call_delta|
|
|
63
|
+
if tool_call_delta[:id] && !tool_call_delta[:id].empty?
|
|
64
|
+
# New tool call (dup strings to avoid mutating input)
|
|
65
|
+
choice_data[index][:tool_calls] << {
|
|
66
|
+
id: tool_call_delta[:id],
|
|
67
|
+
type: tool_call_delta[:type],
|
|
68
|
+
function: {
|
|
69
|
+
name: +(tool_call_delta.dig(:function, :name) || ""),
|
|
70
|
+
arguments: +(tool_call_delta.dig(:function, :arguments) || "")
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
elsif choice_data[index][:tool_calls].any?
|
|
74
|
+
# Continuation - append arguments to last tool call
|
|
75
|
+
last_tool_call = choice_data[index][:tool_calls].last
|
|
76
|
+
if tool_call_delta.dig(:function, :arguments)
|
|
77
|
+
last_tool_call[:function][:arguments] << tool_call_delta[:function][:arguments]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Capture finish_reason
|
|
84
|
+
choice_data[index][:finish_reason] = choice[:finish_reason] if choice[:finish_reason]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build final choices array
|
|
89
|
+
aggregated[:choices] = choice_data.values.sort_by { |c| c[:index] }.map do |choice|
|
|
90
|
+
message = {
|
|
91
|
+
role: choice[:role],
|
|
92
|
+
content: choice[:content].empty? ? nil : choice[:content]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Add tool_calls to message if any
|
|
96
|
+
message[:tool_calls] = choice[:tool_calls] if choice[:tool_calls].any?
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
index: choice[:index],
|
|
100
|
+
message: message,
|
|
101
|
+
finish_reason: choice[:finish_reason]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
aggregated
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Aggregate responses streaming events into a single response structure.
|
|
109
|
+
# Specific to official OpenAI SDK which returns typed event objects.
|
|
110
|
+
# @param events [Array] array of event objects from stream
|
|
111
|
+
# @return [Hash] aggregated response with output, usage, etc.
|
|
112
|
+
def self.aggregate_responses_events(events)
|
|
113
|
+
return {} if events.empty?
|
|
114
|
+
|
|
115
|
+
# Find the response.completed event which has the final response
|
|
116
|
+
completed_event = events.find { |e| e.respond_to?(:type) && e.type == :"response.completed" }
|
|
117
|
+
|
|
118
|
+
if completed_event&.respond_to?(:response)
|
|
119
|
+
response = completed_event.response
|
|
120
|
+
return {
|
|
121
|
+
id: response.respond_to?(:id) ? response.id : nil,
|
|
122
|
+
output: response.respond_to?(:output) ? response.output : nil,
|
|
123
|
+
usage: response.respond_to?(:usage) ? response.usage : nil
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Fallback if no completed event found
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "common"
|
|
7
|
+
require_relative "../../../internal/time"
|
|
8
|
+
require_relative "../../support/otel"
|
|
9
|
+
require_relative "../../support/openai"
|
|
10
|
+
|
|
11
|
+
module Braintrust
|
|
12
|
+
module Contrib
|
|
13
|
+
module OpenAI
|
|
14
|
+
module Instrumentation
|
|
15
|
+
# Responses API instrumentation for OpenAI.
|
|
16
|
+
# Wraps create() and stream() methods to create spans.
|
|
17
|
+
module Responses
|
|
18
|
+
def self.included(base)
|
|
19
|
+
base.prepend(InstanceMethods) unless applied?(base)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.applied?(base)
|
|
23
|
+
base.ancestors.include?(InstanceMethods)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
METADATA_FIELDS = %i[
|
|
27
|
+
model instructions modalities tools parallel_tool_calls
|
|
28
|
+
tool_choice temperature max_tokens top_p frequency_penalty
|
|
29
|
+
presence_penalty seed user metadata store response_format
|
|
30
|
+
reasoning previous_response_id truncation
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
module InstanceMethods
|
|
34
|
+
# Wrap non-streaming create method
|
|
35
|
+
def create(**params)
|
|
36
|
+
client = instance_variable_get(:@client)
|
|
37
|
+
tracer = Braintrust::Contrib.tracer_for(client)
|
|
38
|
+
|
|
39
|
+
tracer.in_span("openai.responses.create") do |span|
|
|
40
|
+
metadata = build_metadata(params)
|
|
41
|
+
|
|
42
|
+
set_input(span, params)
|
|
43
|
+
|
|
44
|
+
response = nil
|
|
45
|
+
time_to_first_token = Braintrust::Internal::Time.measure do
|
|
46
|
+
response = super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
set_output(span, response)
|
|
50
|
+
set_metrics(span, response, time_to_first_token)
|
|
51
|
+
finalize_metadata(span, metadata, response)
|
|
52
|
+
|
|
53
|
+
response
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Wrap streaming method
|
|
58
|
+
# Stores context on stream object for span creation during consumption
|
|
59
|
+
def stream(**params)
|
|
60
|
+
client = instance_variable_get(:@client)
|
|
61
|
+
tracer = Braintrust::Contrib.tracer_for(client)
|
|
62
|
+
metadata = build_metadata(params, stream: true)
|
|
63
|
+
|
|
64
|
+
stream_obj = super
|
|
65
|
+
|
|
66
|
+
Braintrust::Contrib::Context.set!(stream_obj,
|
|
67
|
+
tracer: tracer,
|
|
68
|
+
params: params,
|
|
69
|
+
metadata: metadata,
|
|
70
|
+
responses_instance: self)
|
|
71
|
+
stream_obj
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_metadata(params, stream: false)
|
|
77
|
+
metadata = {
|
|
78
|
+
"provider" => "openai",
|
|
79
|
+
"endpoint" => "/v1/responses"
|
|
80
|
+
}
|
|
81
|
+
metadata["stream"] = true if stream
|
|
82
|
+
Responses::METADATA_FIELDS.each do |field|
|
|
83
|
+
metadata[field.to_s] = params[field] if params.key?(field)
|
|
84
|
+
end
|
|
85
|
+
metadata
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def set_input(span, params)
|
|
89
|
+
return unless params[:input]
|
|
90
|
+
|
|
91
|
+
Support::OTel.set_json_attr(span, "braintrust.input_json", params[:input])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def set_output(span, response)
|
|
95
|
+
return unless response.respond_to?(:output) && response.output
|
|
96
|
+
|
|
97
|
+
Support::OTel.set_json_attr(span, "braintrust.output_json", response.output)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def set_metrics(span, response, time_to_first_token)
|
|
101
|
+
metrics = {}
|
|
102
|
+
if response.respond_to?(:usage) && response.usage
|
|
103
|
+
metrics = Support::OpenAI.parse_usage_tokens(response.usage)
|
|
104
|
+
end
|
|
105
|
+
metrics["time_to_first_token"] = time_to_first_token
|
|
106
|
+
Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def finalize_metadata(span, metadata, response)
|
|
110
|
+
metadata["id"] = response.id if response.respond_to?(:id) && response.id
|
|
111
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Instrumentation for ResponseStream (returned by stream())
|
|
117
|
+
# Aggregates events and creates span lazily when consumed
|
|
118
|
+
module ResponseStream
|
|
119
|
+
def self.included(base)
|
|
120
|
+
base.prepend(InstanceMethods) unless applied?(base)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.applied?(base)
|
|
124
|
+
base.ancestors.include?(InstanceMethods)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
module InstanceMethods
|
|
128
|
+
def each(&block)
|
|
129
|
+
ctx = Braintrust::Contrib::Context.from(self)
|
|
130
|
+
return super unless ctx&.[](:tracer) && !ctx[:consumed]
|
|
131
|
+
|
|
132
|
+
ctx[:consumed] = true
|
|
133
|
+
|
|
134
|
+
tracer = ctx[:tracer]
|
|
135
|
+
params = ctx[:params]
|
|
136
|
+
metadata = ctx[:metadata]
|
|
137
|
+
responses_instance = ctx[:responses_instance]
|
|
138
|
+
aggregated_events = []
|
|
139
|
+
start_time = Braintrust::Internal::Time.measure
|
|
140
|
+
time_to_first_token = nil
|
|
141
|
+
|
|
142
|
+
tracer.in_span("openai.responses.create") do |span|
|
|
143
|
+
responses_instance.send(:set_input, span, params)
|
|
144
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
super do |event|
|
|
148
|
+
time_to_first_token ||= Braintrust::Internal::Time.measure(start_time)
|
|
149
|
+
aggregated_events << event
|
|
150
|
+
block&.call(event)
|
|
151
|
+
end
|
|
152
|
+
rescue => e
|
|
153
|
+
span.record_exception(e)
|
|
154
|
+
span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
|
|
155
|
+
raise
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
finalize_stream_span(span, aggregated_events, time_to_first_token, metadata)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def finalize_stream_span(span, aggregated_events, time_to_first_token, metadata)
|
|
165
|
+
return if aggregated_events.empty?
|
|
166
|
+
|
|
167
|
+
aggregated_output = Common.aggregate_responses_events(aggregated_events)
|
|
168
|
+
Support::OTel.set_json_attr(span, "braintrust.output_json", aggregated_output[:output]) if aggregated_output[:output]
|
|
169
|
+
|
|
170
|
+
# Set metrics
|
|
171
|
+
metrics = {}
|
|
172
|
+
if aggregated_output[:usage]
|
|
173
|
+
metrics = Support::OpenAI.parse_usage_tokens(aggregated_output[:usage])
|
|
174
|
+
end
|
|
175
|
+
metrics["time_to_first_token"] = time_to_first_token
|
|
176
|
+
Support::OTel.set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
177
|
+
|
|
178
|
+
# Update metadata with response fields
|
|
179
|
+
metadata["id"] = aggregated_output[:id] if aggregated_output[:id]
|
|
180
|
+
Support::OTel.set_json_attr(span, "braintrust.metadata", metadata)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration"
|
|
4
|
+
require_relative "deprecated"
|
|
5
|
+
|
|
6
|
+
module Braintrust
|
|
7
|
+
module Contrib
|
|
8
|
+
module OpenAI
|
|
9
|
+
# OpenAI integration for automatic instrumentation.
|
|
10
|
+
# Instruments the official openai gem (not ruby-openai).
|
|
11
|
+
class Integration
|
|
12
|
+
include Braintrust::Contrib::Integration
|
|
13
|
+
|
|
14
|
+
MINIMUM_VERSION = "0.1.0"
|
|
15
|
+
|
|
16
|
+
GEM_NAMES = ["openai"].freeze
|
|
17
|
+
REQUIRE_PATHS = ["openai"].freeze
|
|
18
|
+
|
|
19
|
+
# @return [Symbol] Unique identifier for this integration
|
|
20
|
+
def self.integration_name
|
|
21
|
+
:openai
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] Gem names this integration supports
|
|
25
|
+
def self.gem_names
|
|
26
|
+
GEM_NAMES
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] Require paths for auto-instrument detection
|
|
30
|
+
def self.require_paths
|
|
31
|
+
REQUIRE_PATHS
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] Minimum compatible version
|
|
35
|
+
def self.minimum_version
|
|
36
|
+
MINIMUM_VERSION
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] true if official openai gem is available
|
|
40
|
+
def self.loaded?
|
|
41
|
+
# Check if the official openai gem is loaded (not ruby-openai).
|
|
42
|
+
# The ruby-openai gem also uses "require 'openai'", so we need to distinguish them.
|
|
43
|
+
|
|
44
|
+
# This module is defined ONLY in the official OpenAI gem
|
|
45
|
+
defined?(::OpenAI::Internal) ? true : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Lazy-load the patcher only when actually patching.
|
|
49
|
+
# This keeps the integration stub lightweight.
|
|
50
|
+
# @return [Class] The patcher class
|
|
51
|
+
def self.patchers
|
|
52
|
+
require_relative "patcher"
|
|
53
|
+
[ChatPatcher, ResponsesPatcher]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|