braintrust 0.0.4 → 0.0.6
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 +31 -1
- data/lib/braintrust/state.rb +21 -3
- data/lib/braintrust/trace/contrib/anthropic.rb +85 -208
- data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +135 -0
- data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +447 -0
- data/lib/braintrust/trace/contrib/openai.rb +121 -68
- data/lib/braintrust/trace/tokens.rb +101 -0
- data/lib/braintrust/trace.rb +38 -3
- data/lib/braintrust/version.rb +1 -1
- metadata +6 -3
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../../../tokens"
|
|
6
|
+
require_relative "../../../../logger"
|
|
7
|
+
|
|
8
|
+
module Braintrust
|
|
9
|
+
module Trace
|
|
10
|
+
module Contrib
|
|
11
|
+
module Github
|
|
12
|
+
module Crmne
|
|
13
|
+
module RubyLLM
|
|
14
|
+
# Helper to safely set a JSON attribute on a span
|
|
15
|
+
# Only sets the attribute if obj is present
|
|
16
|
+
# @param span [OpenTelemetry::Trace::Span] the span to set attribute on
|
|
17
|
+
# @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
|
|
18
|
+
# @param obj [Object] the object to serialize to JSON
|
|
19
|
+
# @return [void]
|
|
20
|
+
def self.set_json_attr(span, attr_name, obj)
|
|
21
|
+
return unless obj
|
|
22
|
+
span.set_attribute(attr_name, JSON.generate(obj))
|
|
23
|
+
rescue => e
|
|
24
|
+
Log.debug("Failed to serialize #{attr_name}: #{e.message}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Parse usage tokens from RubyLLM response
|
|
28
|
+
# RubyLLM uses Anthropic-style field naming (input_tokens, output_tokens)
|
|
29
|
+
# @param usage [Hash, Object] usage object from RubyLLM response
|
|
30
|
+
# @return [Hash<String, Integer>] metrics hash with normalized names
|
|
31
|
+
def self.parse_usage_tokens(usage)
|
|
32
|
+
Braintrust::Trace.parse_anthropic_usage_tokens(usage)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Wrap RubyLLM to automatically create spans for chat requests
|
|
36
|
+
# Supports both synchronous and streaming requests
|
|
37
|
+
#
|
|
38
|
+
# Usage:
|
|
39
|
+
# # Wrap the class once (affects all future instances):
|
|
40
|
+
# Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
|
|
41
|
+
#
|
|
42
|
+
# # Or wrap a specific instance:
|
|
43
|
+
# chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
44
|
+
# Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(chat)
|
|
45
|
+
#
|
|
46
|
+
# @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to wrap (if nil, wraps the class)
|
|
47
|
+
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
|
|
48
|
+
def self.wrap(chat = nil, tracer_provider: nil)
|
|
49
|
+
tracer_provider ||= ::OpenTelemetry.tracer_provider
|
|
50
|
+
|
|
51
|
+
# If no chat instance provided, wrap the class globally via initialize hook
|
|
52
|
+
if chat.nil?
|
|
53
|
+
return if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
|
|
54
|
+
|
|
55
|
+
# Create module that wraps initialize to auto-wrap each new instance
|
|
56
|
+
wrapper_module = Module.new do
|
|
57
|
+
define_method(:initialize) do |*args, **kwargs, &block|
|
|
58
|
+
super(*args, **kwargs, &block)
|
|
59
|
+
# Auto-wrap this instance during initialization
|
|
60
|
+
Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(self, tracer_provider: tracer_provider)
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Store reference to wrapper module for cleanup
|
|
66
|
+
::RubyLLM::Chat.instance_variable_set(:@braintrust_wrapper_module, wrapper_module)
|
|
67
|
+
::RubyLLM::Chat.prepend(wrapper_module)
|
|
68
|
+
return nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if already wrapped to make this idempotent
|
|
72
|
+
return chat if chat.instance_variable_get(:@braintrust_wrapped)
|
|
73
|
+
|
|
74
|
+
# Create a wrapper module that intercepts chat.ask
|
|
75
|
+
wrapper = create_wrapper_module(tracer_provider)
|
|
76
|
+
|
|
77
|
+
# Mark as wrapped and prepend the wrapper to the chat instance
|
|
78
|
+
chat.instance_variable_set(:@braintrust_wrapped, true)
|
|
79
|
+
chat.singleton_class.prepend(wrapper)
|
|
80
|
+
chat
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Unwrap RubyLLM to remove Braintrust tracing
|
|
84
|
+
# For class-level unwrapping, removes the initialize override from the wrapper module
|
|
85
|
+
# For instance-level unwrapping, clears the wrapped flag
|
|
86
|
+
#
|
|
87
|
+
# @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to unwrap (if nil, unwraps the class)
|
|
88
|
+
def self.unwrap(chat = nil)
|
|
89
|
+
# If no chat instance provided, unwrap the class globally
|
|
90
|
+
if chat.nil?
|
|
91
|
+
if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
|
|
92
|
+
wrapper_module = ::RubyLLM::Chat.instance_variable_get(:@braintrust_wrapper_module)
|
|
93
|
+
# Redefine initialize to just call super (disables auto-wrapping)
|
|
94
|
+
# We can't actually remove a prepended module, so we make it a no-op
|
|
95
|
+
wrapper_module.module_eval do
|
|
96
|
+
define_method(:initialize) do |*args, **kwargs, &block|
|
|
97
|
+
super(*args, **kwargs, &block)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
::RubyLLM::Chat.remove_instance_variable(:@braintrust_wrapper_module)
|
|
101
|
+
end
|
|
102
|
+
return nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Unwrap instance
|
|
106
|
+
chat.remove_instance_variable(:@braintrust_wrapped) if chat.instance_variable_defined?(:@braintrust_wrapped)
|
|
107
|
+
chat
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Wrap the RubyLLM::Chat class globally
|
|
111
|
+
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
112
|
+
def self.wrap_class(tracer_provider)
|
|
113
|
+
return unless defined?(::RubyLLM::Chat)
|
|
114
|
+
|
|
115
|
+
wrapper = create_wrapper_module(tracer_provider)
|
|
116
|
+
::RubyLLM::Chat.prepend(wrapper)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Create the wrapper module that intercepts chat.ask
|
|
120
|
+
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
121
|
+
# @return [Module] the wrapper module
|
|
122
|
+
def self.create_wrapper_module(tracer_provider)
|
|
123
|
+
Module.new do
|
|
124
|
+
define_method(:ask) do |prompt = nil, **params, &block|
|
|
125
|
+
tracer = tracer_provider.tracer("braintrust")
|
|
126
|
+
|
|
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)
|
|
136
|
+
end
|
|
137
|
+
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
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Handle streaming chat request with tracing
|
|
149
|
+
# @param chat [RubyLLM::Chat] the chat instance
|
|
150
|
+
# @param tracer [OpenTelemetry::Trace::Tracer] the tracer
|
|
151
|
+
# @param prompt [String, nil] the user prompt
|
|
152
|
+
# @param params [Hash] additional parameters
|
|
153
|
+
# @param block [Proc] the streaming block
|
|
154
|
+
def self.handle_streaming_ask(chat, tracer, prompt, params, block)
|
|
155
|
+
# Start span immediately for accurate timing
|
|
156
|
+
span = tracer.start_span("ruby_llm.chat.ask")
|
|
157
|
+
|
|
158
|
+
aggregated_chunks = []
|
|
159
|
+
|
|
160
|
+
# Extract metadata and build input messages
|
|
161
|
+
metadata = extract_metadata(chat, stream: true)
|
|
162
|
+
input_messages = build_input_messages(chat, prompt)
|
|
163
|
+
|
|
164
|
+
# Set input and metadata
|
|
165
|
+
set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
|
|
166
|
+
set_json_attr(span, "braintrust.metadata", metadata)
|
|
167
|
+
|
|
168
|
+
# Call original method, passing aggregated_chunks to the block
|
|
169
|
+
begin
|
|
170
|
+
result = yield aggregated_chunks
|
|
171
|
+
rescue => e
|
|
172
|
+
span.record_exception(e)
|
|
173
|
+
span.status = ::OpenTelemetry::Trace::Status.error("RubyLLM error: #{e.message}")
|
|
174
|
+
span.finish
|
|
175
|
+
raise
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Set output and metrics from aggregated chunks
|
|
179
|
+
capture_streaming_output(span, aggregated_chunks, result)
|
|
180
|
+
span.finish
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Handle non-streaming chat request with tracing
|
|
185
|
+
# @param chat [RubyLLM::Chat] the chat instance
|
|
186
|
+
# @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)
|
|
190
|
+
# Start span immediately for accurate timing
|
|
191
|
+
span = tracer.start_span("ruby_llm.chat.ask")
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
# Extract metadata and build input messages
|
|
195
|
+
metadata = extract_metadata(chat)
|
|
196
|
+
input_messages = build_input_messages(chat, prompt)
|
|
197
|
+
set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
|
|
198
|
+
|
|
199
|
+
# Remember message count before the call (for tool call detection)
|
|
200
|
+
messages_before_count = (chat.respond_to?(:messages) && chat.messages) ? chat.messages.length : 0
|
|
201
|
+
|
|
202
|
+
# Call the original method
|
|
203
|
+
response = yield
|
|
204
|
+
|
|
205
|
+
# Capture output and metrics
|
|
206
|
+
capture_non_streaming_output(span, chat, response, messages_before_count)
|
|
207
|
+
|
|
208
|
+
# Set metadata
|
|
209
|
+
set_json_attr(span, "braintrust.metadata", metadata)
|
|
210
|
+
|
|
211
|
+
response
|
|
212
|
+
ensure
|
|
213
|
+
span.finish
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Extract metadata from chat instance (provider, model, tools, stream flag)
|
|
218
|
+
# @param chat [RubyLLM::Chat] the chat instance
|
|
219
|
+
# @param stream [Boolean] whether this is a streaming request
|
|
220
|
+
# @return [Hash] metadata hash
|
|
221
|
+
def self.extract_metadata(chat, stream: false)
|
|
222
|
+
metadata = {"provider" => "ruby_llm"}
|
|
223
|
+
metadata["stream"] = true if stream
|
|
224
|
+
|
|
225
|
+
# Extract model
|
|
226
|
+
if chat.respond_to?(:model) && chat.model
|
|
227
|
+
model = chat.model.respond_to?(:id) ? chat.model.id : chat.model.to_s
|
|
228
|
+
metadata["model"] = model
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Extract tools (only for non-streaming)
|
|
232
|
+
if !stream && chat.respond_to?(:tools) && chat.tools&.any?
|
|
233
|
+
metadata["tools"] = extract_tools_metadata(chat)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
metadata
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Extract tools metadata from chat instance
|
|
240
|
+
# @param chat [RubyLLM::Chat] the chat instance
|
|
241
|
+
# @return [Array<Hash>] array of tool schemas
|
|
242
|
+
def self.extract_tools_metadata(chat)
|
|
243
|
+
provider = chat.instance_variable_get(:@provider) if chat.instance_variable_defined?(:@provider)
|
|
244
|
+
|
|
245
|
+
chat.tools.map do |_name, tool|
|
|
246
|
+
format_tool_schema(tool, provider)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Format a tool into OpenAI-compatible schema
|
|
251
|
+
# @param tool [Object] the tool object
|
|
252
|
+
# @param provider [Object, nil] the provider instance
|
|
253
|
+
# @return [Hash] tool schema
|
|
254
|
+
def self.format_tool_schema(tool, provider)
|
|
255
|
+
tool_schema = nil
|
|
256
|
+
|
|
257
|
+
# Use provider-specific tool_for method if available
|
|
258
|
+
if provider
|
|
259
|
+
begin
|
|
260
|
+
tool_schema = if provider.is_a?(::RubyLLM::Providers::OpenAI)
|
|
261
|
+
::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
|
|
262
|
+
elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
|
|
263
|
+
::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
|
|
264
|
+
elsif tool.respond_to?(:params_schema) && tool.params_schema
|
|
265
|
+
build_basic_tool_schema(tool)
|
|
266
|
+
else
|
|
267
|
+
build_minimal_tool_schema(tool)
|
|
268
|
+
end
|
|
269
|
+
rescue NameError, ArgumentError => e
|
|
270
|
+
# If provider-specific tool_for fails, fall back to basic format
|
|
271
|
+
Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
|
|
272
|
+
tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
|
|
273
|
+
end
|
|
274
|
+
else
|
|
275
|
+
# No provider, use basic format with params_schema if available
|
|
276
|
+
tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Strip RubyLLM-specific fields to match native OpenAI format
|
|
280
|
+
# Handle both symbol and string keys
|
|
281
|
+
function_key = tool_schema&.key?(:function) ? :function : "function"
|
|
282
|
+
if tool_schema && tool_schema[function_key]
|
|
283
|
+
tool_params = tool_schema[function_key][:parameters] || tool_schema[function_key]["parameters"]
|
|
284
|
+
if tool_params.is_a?(Hash)
|
|
285
|
+
tool_params.delete("strict")
|
|
286
|
+
tool_params.delete(:strict)
|
|
287
|
+
tool_params.delete("additionalProperties")
|
|
288
|
+
tool_params.delete(:additionalProperties)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
tool_schema
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Build a basic tool schema with parameters
|
|
296
|
+
# @param tool [Object] the tool object
|
|
297
|
+
# @return [Hash] tool schema
|
|
298
|
+
def self.build_basic_tool_schema(tool)
|
|
299
|
+
{
|
|
300
|
+
"type" => "function",
|
|
301
|
+
"function" => {
|
|
302
|
+
"name" => tool.name.to_s,
|
|
303
|
+
"description" => tool.description,
|
|
304
|
+
"parameters" => tool.params_schema
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Build a minimal tool schema without parameters
|
|
310
|
+
# @param tool [Object] the tool object
|
|
311
|
+
# @return [Hash] tool schema
|
|
312
|
+
def self.build_minimal_tool_schema(tool)
|
|
313
|
+
{
|
|
314
|
+
"type" => "function",
|
|
315
|
+
"function" => {
|
|
316
|
+
"name" => tool.name.to_s,
|
|
317
|
+
"description" => tool.description,
|
|
318
|
+
"parameters" => {}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Build input messages array from chat history and prompt
|
|
324
|
+
# @param chat [RubyLLM::Chat] the chat instance
|
|
325
|
+
# @param prompt [String, nil] the user prompt
|
|
326
|
+
# @return [Array<Hash>] array of message hashes
|
|
327
|
+
def self.build_input_messages(chat, prompt)
|
|
328
|
+
input_messages = []
|
|
329
|
+
|
|
330
|
+
# Add conversation history
|
|
331
|
+
if chat.respond_to?(:messages) && chat.messages&.any?
|
|
332
|
+
input_messages = chat.messages.map { |m| m.respond_to?(:to_h) ? m.to_h : m }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Add current prompt
|
|
336
|
+
input_messages << {role: "user", content: prompt} if prompt
|
|
337
|
+
|
|
338
|
+
input_messages
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Capture streaming output and metrics
|
|
342
|
+
# @param span [OpenTelemetry::Trace::Span] the span
|
|
343
|
+
# @param aggregated_chunks [Array] the aggregated chunks
|
|
344
|
+
# @param result [Object] the result object
|
|
345
|
+
def self.capture_streaming_output(span, aggregated_chunks, result)
|
|
346
|
+
return if aggregated_chunks.empty?
|
|
347
|
+
|
|
348
|
+
# Aggregate content from chunks
|
|
349
|
+
aggregated_content = aggregated_chunks.map { |c|
|
|
350
|
+
c.respond_to?(:content) ? c.content : c.to_s
|
|
351
|
+
}.join
|
|
352
|
+
|
|
353
|
+
output = [{
|
|
354
|
+
role: "assistant",
|
|
355
|
+
content: aggregated_content
|
|
356
|
+
}]
|
|
357
|
+
set_json_attr(span, "braintrust.output_json", output)
|
|
358
|
+
|
|
359
|
+
# Try to extract usage from the result
|
|
360
|
+
if result.respond_to?(:usage) && result.usage
|
|
361
|
+
metrics = parse_usage_tokens(result.usage)
|
|
362
|
+
set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Capture non-streaming output and metrics
|
|
367
|
+
# @param span [OpenTelemetry::Trace::Span] the span
|
|
368
|
+
# @param chat [RubyLLM::Chat] the chat instance
|
|
369
|
+
# @param response [Object] the response object
|
|
370
|
+
# @param messages_before_count [Integer] message count before the call
|
|
371
|
+
def self.capture_non_streaming_output(span, chat, response, messages_before_count)
|
|
372
|
+
return unless response
|
|
373
|
+
|
|
374
|
+
# Build message object from response
|
|
375
|
+
message = {
|
|
376
|
+
"role" => "assistant",
|
|
377
|
+
"content" => nil
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# Add content if it's a simple text response
|
|
381
|
+
if response.respond_to?(:content) && response.content && !response.content.empty?
|
|
382
|
+
message["content"] = response.content
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Check if there are tool calls in the messages history
|
|
386
|
+
if chat.respond_to?(:messages) && chat.messages
|
|
387
|
+
assistant_msg = chat.messages[(messages_before_count + 1)..].find { |m|
|
|
388
|
+
m.role.to_s == "assistant" && m.respond_to?(:tool_calls) && m.tool_calls&.any?
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if assistant_msg&.tool_calls&.any?
|
|
392
|
+
message["tool_calls"] = format_tool_calls(assistant_msg.tool_calls)
|
|
393
|
+
message["content"] = nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Format as OpenAI choices[] structure
|
|
398
|
+
output = [{
|
|
399
|
+
"index" => 0,
|
|
400
|
+
"message" => message,
|
|
401
|
+
"finish_reason" => message["tool_calls"] ? "tool_calls" : "stop"
|
|
402
|
+
}]
|
|
403
|
+
|
|
404
|
+
set_json_attr(span, "braintrust.output_json", output)
|
|
405
|
+
|
|
406
|
+
# Set metrics (token usage)
|
|
407
|
+
if response.respond_to?(:to_h)
|
|
408
|
+
response_hash = response.to_h
|
|
409
|
+
usage = {
|
|
410
|
+
"input_tokens" => response_hash[:input_tokens],
|
|
411
|
+
"output_tokens" => response_hash[:output_tokens],
|
|
412
|
+
"cached_tokens" => response_hash[:cached_tokens],
|
|
413
|
+
"cache_creation_tokens" => response_hash[:cache_creation_tokens]
|
|
414
|
+
}.compact
|
|
415
|
+
|
|
416
|
+
unless usage.empty?
|
|
417
|
+
metrics = parse_usage_tokens(usage)
|
|
418
|
+
set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Format tool calls into OpenAI format
|
|
424
|
+
# @param tool_calls [Hash, Array] the tool calls
|
|
425
|
+
# @return [Array<Hash>] formatted tool calls
|
|
426
|
+
def self.format_tool_calls(tool_calls)
|
|
427
|
+
tool_calls.map do |_id, tc|
|
|
428
|
+
# Ensure arguments is a JSON string (OpenAI format)
|
|
429
|
+
args = tc.arguments
|
|
430
|
+
args_string = args.is_a?(String) ? args : JSON.generate(args)
|
|
431
|
+
|
|
432
|
+
{
|
|
433
|
+
"id" => tc.id,
|
|
434
|
+
"type" => "function",
|
|
435
|
+
"function" => {
|
|
436
|
+
"name" => tc.name,
|
|
437
|
+
"arguments" => args_string
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "opentelemetry/sdk"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "../tokens"
|
|
5
6
|
|
|
6
7
|
module Braintrust
|
|
7
8
|
module Trace
|
|
@@ -17,72 +18,11 @@ module Braintrust
|
|
|
17
18
|
span.set_attribute(attr_name, JSON.generate(obj))
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
# Parse usage tokens from OpenAI API response
|
|
21
|
-
# Maps OpenAI field names to Braintrust standard names:
|
|
22
|
-
# - input_tokens → prompt_tokens
|
|
23
|
-
# - output_tokens → completion_tokens
|
|
24
|
-
# - total_tokens → tokens
|
|
25
|
-
# - *_tokens_details.* → prefix_*
|
|
26
|
-
#
|
|
21
|
+
# Parse usage tokens from OpenAI API response
|
|
27
22
|
# @param usage [Hash, Object] usage object from OpenAI response
|
|
28
23
|
# @return [Hash<String, Integer>] metrics hash with normalized names
|
|
29
24
|
def self.parse_usage_tokens(usage)
|
|
30
|
-
|
|
31
|
-
return metrics unless usage
|
|
32
|
-
|
|
33
|
-
# Convert to hash if it's an object
|
|
34
|
-
usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
|
|
35
|
-
|
|
36
|
-
usage_hash.each do |key, value|
|
|
37
|
-
key_str = key.to_s
|
|
38
|
-
|
|
39
|
-
# Handle nested *_tokens_details objects
|
|
40
|
-
if key_str.end_with?("_tokens_details")
|
|
41
|
-
# Convert to hash if it's an object (OpenAI gem returns objects)
|
|
42
|
-
details_hash = value.respond_to?(:to_h) ? value.to_h : value
|
|
43
|
-
next unless details_hash.is_a?(Hash)
|
|
44
|
-
|
|
45
|
-
# Extract prefix (e.g., "prompt" from "prompt_tokens_details")
|
|
46
|
-
prefix = key_str.sub(/_tokens_details$/, "")
|
|
47
|
-
# Translate "input" → "prompt", "output" → "completion"
|
|
48
|
-
prefix = translate_metric_prefix(prefix)
|
|
49
|
-
|
|
50
|
-
# Process nested fields (e.g., cached_tokens, reasoning_tokens)
|
|
51
|
-
details_hash.each do |detail_key, detail_value|
|
|
52
|
-
next unless detail_value.is_a?(Numeric)
|
|
53
|
-
metrics["#{prefix}_#{detail_key}"] = detail_value.to_i
|
|
54
|
-
end
|
|
55
|
-
elsif value.is_a?(Numeric)
|
|
56
|
-
# Handle top-level token fields
|
|
57
|
-
case key_str
|
|
58
|
-
when "input_tokens"
|
|
59
|
-
metrics["prompt_tokens"] = value.to_i
|
|
60
|
-
when "output_tokens"
|
|
61
|
-
metrics["completion_tokens"] = value.to_i
|
|
62
|
-
when "total_tokens"
|
|
63
|
-
metrics["tokens"] = value.to_i
|
|
64
|
-
else
|
|
65
|
-
# Keep other numeric fields as-is (future-proofing)
|
|
66
|
-
metrics[key_str] = value.to_i
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
metrics
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Translate metric prefix to be consistent between different API formats
|
|
75
|
-
# @param prefix [String] the prefix to translate
|
|
76
|
-
# @return [String] translated prefix
|
|
77
|
-
def self.translate_metric_prefix(prefix)
|
|
78
|
-
case prefix
|
|
79
|
-
when "input"
|
|
80
|
-
"prompt"
|
|
81
|
-
when "output"
|
|
82
|
-
"completion"
|
|
83
|
-
else
|
|
84
|
-
prefix
|
|
85
|
-
end
|
|
25
|
+
Braintrust::Trace.parse_openai_usage_tokens(usage)
|
|
86
26
|
end
|
|
87
27
|
|
|
88
28
|
# Aggregate streaming chunks into a single response structure
|
|
@@ -124,7 +64,7 @@ module Braintrust
|
|
|
124
64
|
choice_data[index] ||= {
|
|
125
65
|
index: index,
|
|
126
66
|
role: nil,
|
|
127
|
-
content: "",
|
|
67
|
+
content: +"",
|
|
128
68
|
tool_calls: [],
|
|
129
69
|
finish_reason: nil
|
|
130
70
|
}
|
|
@@ -136,7 +76,7 @@ module Braintrust
|
|
|
136
76
|
|
|
137
77
|
# Aggregate content
|
|
138
78
|
if delta[:content]
|
|
139
|
-
choice_data[index][:content]
|
|
79
|
+
choice_data[index][:content] << delta[:content]
|
|
140
80
|
end
|
|
141
81
|
|
|
142
82
|
# Aggregate tool_calls (similar to Go SDK logic)
|
|
@@ -149,15 +89,15 @@ module Braintrust
|
|
|
149
89
|
id: tool_call_delta[:id],
|
|
150
90
|
type: tool_call_delta[:type],
|
|
151
91
|
function: {
|
|
152
|
-
name: tool_call_delta.dig(:function, :name) || "",
|
|
153
|
-
arguments: tool_call_delta.dig(:function, :arguments) || ""
|
|
92
|
+
name: tool_call_delta.dig(:function, :name) || +"",
|
|
93
|
+
arguments: tool_call_delta.dig(:function, :arguments) || +""
|
|
154
94
|
}
|
|
155
95
|
}
|
|
156
96
|
elsif choice_data[index][:tool_calls].any?
|
|
157
97
|
# Continuation - append arguments to last tool call
|
|
158
98
|
last_tool_call = choice_data[index][:tool_calls].last
|
|
159
99
|
if tool_call_delta.dig(:function, :arguments)
|
|
160
|
-
last_tool_call[:function][:arguments]
|
|
100
|
+
last_tool_call[:function][:arguments] << tool_call_delta[:function][:arguments]
|
|
161
101
|
end
|
|
162
102
|
end
|
|
163
103
|
end
|
|
@@ -353,6 +293,119 @@ module Braintrust
|
|
|
353
293
|
|
|
354
294
|
stream
|
|
355
295
|
end
|
|
296
|
+
|
|
297
|
+
# Wrap stream for streaming chat completions (returns ChatCompletionStream with convenience methods)
|
|
298
|
+
define_method(:stream) do |**params|
|
|
299
|
+
tracer = tracer_provider.tracer("braintrust")
|
|
300
|
+
metadata = {
|
|
301
|
+
"provider" => "openai",
|
|
302
|
+
"endpoint" => "/v1/chat/completions"
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# Start span with proper context (will be child of current span if any)
|
|
306
|
+
span = tracer.start_span("openai.chat.completions.create")
|
|
307
|
+
|
|
308
|
+
# Capture request metadata fields
|
|
309
|
+
metadata_fields = %i[
|
|
310
|
+
model frequency_penalty logit_bias logprobs max_tokens n
|
|
311
|
+
presence_penalty response_format seed service_tier stop
|
|
312
|
+
stream stream_options temperature top_p top_logprobs
|
|
313
|
+
tools tool_choice parallel_tool_calls user functions function_call
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
metadata_fields.each do |field|
|
|
317
|
+
metadata[field.to_s] = params[field] if params.key?(field)
|
|
318
|
+
end
|
|
319
|
+
metadata["stream"] = true # Explicitly mark as streaming
|
|
320
|
+
|
|
321
|
+
# Set input messages as JSON
|
|
322
|
+
if params[:messages]
|
|
323
|
+
messages_array = params[:messages].map(&:to_h)
|
|
324
|
+
span.set_attribute("braintrust.input_json", JSON.generate(messages_array))
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Set initial metadata
|
|
328
|
+
span.set_attribute("braintrust.metadata", JSON.generate(metadata))
|
|
329
|
+
|
|
330
|
+
# Call the original stream method with error handling
|
|
331
|
+
begin
|
|
332
|
+
stream = super(**params)
|
|
333
|
+
rescue => e
|
|
334
|
+
# Record exception if stream creation fails
|
|
335
|
+
span.record_exception(e)
|
|
336
|
+
span.status = ::OpenTelemetry::Trace::Status.error("OpenAI API error: #{e.message}")
|
|
337
|
+
span.finish
|
|
338
|
+
raise
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Local helper for setting JSON attributes
|
|
342
|
+
set_json_attr = ->(attr_name, obj) { Braintrust::Trace::OpenAI.set_json_attr(span, attr_name, obj) }
|
|
343
|
+
|
|
344
|
+
# Helper to extract metadata from SDK's internal snapshot
|
|
345
|
+
extract_stream_metadata = lambda do
|
|
346
|
+
# Access the SDK's internal accumulated completion snapshot
|
|
347
|
+
snapshot = stream.current_completion_snapshot
|
|
348
|
+
return unless snapshot
|
|
349
|
+
|
|
350
|
+
# Set output from accumulated choices
|
|
351
|
+
if snapshot.choices&.any?
|
|
352
|
+
choices_array = snapshot.choices.map(&:to_h)
|
|
353
|
+
set_json_attr.call("braintrust.output_json", choices_array)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Set metrics if usage is available
|
|
357
|
+
if snapshot.usage
|
|
358
|
+
metrics = Braintrust::Trace::OpenAI.parse_usage_tokens(snapshot.usage)
|
|
359
|
+
set_json_attr.call("braintrust.metrics", metrics) unless metrics.empty?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Update metadata with response fields
|
|
363
|
+
metadata["id"] = snapshot.id if snapshot.respond_to?(:id) && snapshot.id
|
|
364
|
+
metadata["created"] = snapshot.created if snapshot.respond_to?(:created) && snapshot.created
|
|
365
|
+
metadata["model"] = snapshot.model if snapshot.respond_to?(:model) && snapshot.model
|
|
366
|
+
metadata["system_fingerprint"] = snapshot.system_fingerprint if snapshot.respond_to?(:system_fingerprint) && snapshot.system_fingerprint
|
|
367
|
+
set_json_attr.call("braintrust.metadata", metadata)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Prevent double-finish of span
|
|
371
|
+
finish_braintrust_span = lambda do
|
|
372
|
+
return if stream.instance_variable_get(:@braintrust_span_finished)
|
|
373
|
+
stream.instance_variable_set(:@braintrust_span_finished, true)
|
|
374
|
+
extract_stream_metadata.call
|
|
375
|
+
span.finish
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Wrap .each() method - this is the core consumption method
|
|
379
|
+
original_each = stream.method(:each)
|
|
380
|
+
stream.define_singleton_method(:each) do |&block|
|
|
381
|
+
original_each.call(&block)
|
|
382
|
+
rescue => e
|
|
383
|
+
span.record_exception(e)
|
|
384
|
+
span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
|
|
385
|
+
raise
|
|
386
|
+
ensure
|
|
387
|
+
finish_braintrust_span.call
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Wrap .text() method - returns enumerable for text deltas
|
|
391
|
+
original_text = stream.method(:text)
|
|
392
|
+
stream.define_singleton_method(:text) do
|
|
393
|
+
text_enum = original_text.call
|
|
394
|
+
# Wrap the returned enumerable's .each method
|
|
395
|
+
text_enum.define_singleton_method(:each) do |&block|
|
|
396
|
+
super(&block)
|
|
397
|
+
rescue => e
|
|
398
|
+
span.record_exception(e)
|
|
399
|
+
span.status = ::OpenTelemetry::Trace::Status.error("Streaming error: #{e.message}")
|
|
400
|
+
raise
|
|
401
|
+
ensure
|
|
402
|
+
finish_braintrust_span.call
|
|
403
|
+
end
|
|
404
|
+
text_enum
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
stream
|
|
408
|
+
end
|
|
356
409
|
end
|
|
357
410
|
|
|
358
411
|
# Prepend the wrapper to the completions resource
|