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,631 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "opentelemetry/sdk"
|
|
4
|
-
require "json"
|
|
5
|
-
require_relative "../../../tokens"
|
|
6
|
-
require_relative "../../../../logger"
|
|
7
|
-
require_relative "../../../../internal/encoding"
|
|
8
|
-
|
|
9
|
-
module Braintrust
|
|
10
|
-
module Trace
|
|
11
|
-
module Contrib
|
|
12
|
-
module Github
|
|
13
|
-
module Crmne
|
|
14
|
-
module RubyLLM
|
|
15
|
-
# Helper to safely set a JSON attribute on a span
|
|
16
|
-
# Only sets the attribute if obj is present
|
|
17
|
-
# @param span [OpenTelemetry::Trace::Span] the span to set attribute on
|
|
18
|
-
# @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
|
|
19
|
-
# @param obj [Object] the object to serialize to JSON
|
|
20
|
-
# @return [void]
|
|
21
|
-
def self.set_json_attr(span, attr_name, obj)
|
|
22
|
-
return unless obj
|
|
23
|
-
span.set_attribute(attr_name, JSON.generate(obj))
|
|
24
|
-
rescue => e
|
|
25
|
-
Log.debug("Failed to serialize #{attr_name}: #{e.message}")
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Parse usage tokens from RubyLLM response
|
|
29
|
-
# RubyLLM uses Anthropic-style field naming (input_tokens, output_tokens)
|
|
30
|
-
# @param usage [Hash, Object] usage object from RubyLLM response
|
|
31
|
-
# @return [Hash<String, Integer>] metrics hash with normalized names
|
|
32
|
-
def self.parse_usage_tokens(usage)
|
|
33
|
-
Braintrust::Trace.parse_anthropic_usage_tokens(usage)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Wrap RubyLLM to automatically create spans for chat requests
|
|
37
|
-
# Supports both synchronous and streaming requests
|
|
38
|
-
#
|
|
39
|
-
# Usage:
|
|
40
|
-
# # Wrap the class once (affects all future instances):
|
|
41
|
-
# Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
|
|
42
|
-
#
|
|
43
|
-
# # Or wrap a specific instance:
|
|
44
|
-
# chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
45
|
-
# Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(chat)
|
|
46
|
-
#
|
|
47
|
-
# @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to wrap (if nil, wraps the class)
|
|
48
|
-
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
|
|
49
|
-
def self.wrap(chat = nil, tracer_provider: nil)
|
|
50
|
-
tracer_provider ||= ::OpenTelemetry.tracer_provider
|
|
51
|
-
|
|
52
|
-
# If no chat instance provided, wrap the class globally via initialize hook
|
|
53
|
-
if chat.nil?
|
|
54
|
-
return if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
|
|
55
|
-
|
|
56
|
-
# Create module that wraps initialize to auto-wrap each new instance
|
|
57
|
-
wrapper_module = Module.new do
|
|
58
|
-
define_method(:initialize) do |*args, **kwargs, &block|
|
|
59
|
-
super(*args, **kwargs, &block)
|
|
60
|
-
# Auto-wrap this instance during initialization
|
|
61
|
-
Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap(self, tracer_provider: tracer_provider)
|
|
62
|
-
self
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Store reference to wrapper module for cleanup
|
|
67
|
-
::RubyLLM::Chat.instance_variable_set(:@braintrust_wrapper_module, wrapper_module)
|
|
68
|
-
::RubyLLM::Chat.prepend(wrapper_module)
|
|
69
|
-
return nil
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Check if already wrapped to make this idempotent
|
|
73
|
-
return chat if chat.instance_variable_get(:@braintrust_wrapped)
|
|
74
|
-
|
|
75
|
-
# Create a wrapper module that intercepts chat.complete
|
|
76
|
-
wrapper = create_wrapper_module(tracer_provider)
|
|
77
|
-
|
|
78
|
-
# Mark as wrapped and prepend the wrapper to the chat instance
|
|
79
|
-
chat.instance_variable_set(:@braintrust_wrapped, true)
|
|
80
|
-
chat.singleton_class.prepend(wrapper)
|
|
81
|
-
|
|
82
|
-
# Register tool callbacks for tool span creation
|
|
83
|
-
register_tool_callbacks(chat, tracer_provider)
|
|
84
|
-
|
|
85
|
-
chat
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Register callbacks for tool execution tracing
|
|
89
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
90
|
-
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
91
|
-
def self.register_tool_callbacks(chat, tracer_provider)
|
|
92
|
-
tracer = tracer_provider.tracer("braintrust")
|
|
93
|
-
|
|
94
|
-
# Track tool spans by tool_call_id
|
|
95
|
-
tool_spans = {}
|
|
96
|
-
|
|
97
|
-
# Start tool span when tool is called
|
|
98
|
-
chat.on_tool_call do |tool_call|
|
|
99
|
-
span = tracer.start_span("ruby_llm.tool.#{tool_call.name}")
|
|
100
|
-
set_json_attr(span, "braintrust.span_attributes", {type: "tool"})
|
|
101
|
-
span.set_attribute("tool.name", tool_call.name)
|
|
102
|
-
span.set_attribute("tool.call_id", tool_call.id)
|
|
103
|
-
|
|
104
|
-
# Store tool input
|
|
105
|
-
input = {
|
|
106
|
-
"name" => tool_call.name,
|
|
107
|
-
"arguments" => tool_call.arguments
|
|
108
|
-
}
|
|
109
|
-
set_json_attr(span, "braintrust.input_json", input)
|
|
110
|
-
|
|
111
|
-
tool_spans[tool_call.id] = span
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# End tool span when result is received
|
|
115
|
-
chat.on_tool_result do |result|
|
|
116
|
-
# Find the most recent tool span (RubyLLM doesn't pass tool_call_id to on_tool_result)
|
|
117
|
-
# The spans are processed in order, so we can use the first unfinished one
|
|
118
|
-
tool_call_id, span = tool_spans.find { |_id, s| s }
|
|
119
|
-
if span
|
|
120
|
-
# Store tool output
|
|
121
|
-
set_json_attr(span, "braintrust.output_json", result)
|
|
122
|
-
span.finish
|
|
123
|
-
tool_spans.delete(tool_call_id)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# Unwrap RubyLLM to remove Braintrust tracing
|
|
129
|
-
# For class-level unwrapping, removes the initialize override from the wrapper module
|
|
130
|
-
# For instance-level unwrapping, clears the wrapped flag
|
|
131
|
-
#
|
|
132
|
-
# @param chat [RubyLLM::Chat, nil] the RubyLLM chat instance to unwrap (if nil, unwraps the class)
|
|
133
|
-
def self.unwrap(chat = nil)
|
|
134
|
-
# If no chat instance provided, unwrap the class globally
|
|
135
|
-
if chat.nil?
|
|
136
|
-
if defined?(::RubyLLM::Chat) && ::RubyLLM::Chat.instance_variable_defined?(:@braintrust_wrapper_module)
|
|
137
|
-
wrapper_module = ::RubyLLM::Chat.instance_variable_get(:@braintrust_wrapper_module)
|
|
138
|
-
# Redefine initialize to just call super (disables auto-wrapping)
|
|
139
|
-
# We can't actually remove a prepended module, so we make it a no-op
|
|
140
|
-
wrapper_module.module_eval do
|
|
141
|
-
define_method(:initialize) do |*args, **kwargs, &block|
|
|
142
|
-
super(*args, **kwargs, &block)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
::RubyLLM::Chat.remove_instance_variable(:@braintrust_wrapper_module)
|
|
146
|
-
end
|
|
147
|
-
return nil
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# Unwrap instance
|
|
151
|
-
chat.remove_instance_variable(:@braintrust_wrapped) if chat.instance_variable_defined?(:@braintrust_wrapped)
|
|
152
|
-
chat
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# Wrap the RubyLLM::Chat class globally
|
|
156
|
-
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
157
|
-
def self.wrap_class(tracer_provider)
|
|
158
|
-
return unless defined?(::RubyLLM::Chat)
|
|
159
|
-
|
|
160
|
-
wrapper = create_wrapper_module(tracer_provider)
|
|
161
|
-
::RubyLLM::Chat.prepend(wrapper)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# Create the wrapper module that intercepts chat.complete
|
|
165
|
-
# We wrap complete() instead of ask() because:
|
|
166
|
-
# - ask() internally calls complete() for the actual API call
|
|
167
|
-
# - ActiveRecord integration (acts_as_chat) calls complete() directly
|
|
168
|
-
# - This ensures all LLM calls are traced regardless of entry point
|
|
169
|
-
#
|
|
170
|
-
# Important: RubyLLM's complete() calls itself recursively for tool execution.
|
|
171
|
-
# We only create a span for the outermost call to avoid duplicate spans.
|
|
172
|
-
# Tool execution is traced separately via on_tool_call/on_tool_result callbacks.
|
|
173
|
-
#
|
|
174
|
-
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
175
|
-
# @return [Module] the wrapper module
|
|
176
|
-
def self.create_wrapper_module(tracer_provider)
|
|
177
|
-
Module.new do
|
|
178
|
-
define_method(:complete) do |&block|
|
|
179
|
-
# Check if we're already inside a traced complete() call
|
|
180
|
-
# If so, just call super without creating a new span
|
|
181
|
-
if @braintrust_in_complete
|
|
182
|
-
if block
|
|
183
|
-
return super(&block)
|
|
184
|
-
else
|
|
185
|
-
return super()
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
tracer = tracer_provider.tracer("braintrust")
|
|
190
|
-
|
|
191
|
-
# Mark that we're inside a complete() call
|
|
192
|
-
@braintrust_in_complete = true
|
|
193
|
-
|
|
194
|
-
begin
|
|
195
|
-
if block
|
|
196
|
-
# Handle streaming request
|
|
197
|
-
wrapped_block = proc do |chunk|
|
|
198
|
-
block.call(chunk)
|
|
199
|
-
end
|
|
200
|
-
Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_streaming_complete(self, tracer, block) do |aggregated_chunks|
|
|
201
|
-
super(&proc do |chunk|
|
|
202
|
-
aggregated_chunks << chunk
|
|
203
|
-
wrapped_block.call(chunk)
|
|
204
|
-
end)
|
|
205
|
-
end
|
|
206
|
-
else
|
|
207
|
-
# Handle non-streaming request
|
|
208
|
-
Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.handle_non_streaming_complete(self, tracer) do
|
|
209
|
-
super()
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
ensure
|
|
213
|
-
@braintrust_in_complete = false
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# Handle streaming complete request with tracing
|
|
220
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
221
|
-
# @param tracer [OpenTelemetry::Trace::Tracer] the tracer
|
|
222
|
-
# @param block [Proc] the streaming block
|
|
223
|
-
def self.handle_streaming_complete(chat, tracer, block)
|
|
224
|
-
# Start span immediately for accurate timing
|
|
225
|
-
span = tracer.start_span("ruby_llm.chat")
|
|
226
|
-
|
|
227
|
-
aggregated_chunks = []
|
|
228
|
-
|
|
229
|
-
# Extract metadata and build input messages
|
|
230
|
-
# For complete(), messages are already in chat history (no prompt param)
|
|
231
|
-
metadata = extract_metadata(chat, stream: true)
|
|
232
|
-
input_messages = build_input_messages(chat, nil)
|
|
233
|
-
|
|
234
|
-
# Set input and metadata
|
|
235
|
-
set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
|
|
236
|
-
set_json_attr(span, "braintrust.metadata", metadata)
|
|
237
|
-
|
|
238
|
-
# Call original method, passing aggregated_chunks to the block
|
|
239
|
-
begin
|
|
240
|
-
result = yield aggregated_chunks
|
|
241
|
-
rescue => e
|
|
242
|
-
span.record_exception(e)
|
|
243
|
-
span.status = ::OpenTelemetry::Trace::Status.error("RubyLLM error: #{e.message}")
|
|
244
|
-
span.finish
|
|
245
|
-
raise
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Set output and metrics from aggregated chunks
|
|
249
|
-
capture_streaming_output(span, aggregated_chunks, result)
|
|
250
|
-
span.finish
|
|
251
|
-
result
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Handle non-streaming complete request with tracing
|
|
255
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
256
|
-
# @param tracer [OpenTelemetry::Trace::Tracer] the tracer
|
|
257
|
-
def self.handle_non_streaming_complete(chat, tracer)
|
|
258
|
-
# Start span immediately for accurate timing
|
|
259
|
-
span = tracer.start_span("ruby_llm.chat")
|
|
260
|
-
|
|
261
|
-
begin
|
|
262
|
-
# Extract metadata and build input messages
|
|
263
|
-
# For complete(), messages are already in chat history (no prompt param)
|
|
264
|
-
metadata = extract_metadata(chat)
|
|
265
|
-
input_messages = build_input_messages(chat, nil)
|
|
266
|
-
set_json_attr(span, "braintrust.input_json", input_messages) if input_messages.any?
|
|
267
|
-
|
|
268
|
-
# Remember message count before the call (for tool call detection)
|
|
269
|
-
messages_before_count = (chat.respond_to?(:messages) && chat.messages) ? chat.messages.length : 0
|
|
270
|
-
|
|
271
|
-
# Call the original method
|
|
272
|
-
response = yield
|
|
273
|
-
|
|
274
|
-
# Capture output and metrics
|
|
275
|
-
capture_non_streaming_output(span, chat, response, messages_before_count)
|
|
276
|
-
|
|
277
|
-
# Set metadata
|
|
278
|
-
set_json_attr(span, "braintrust.metadata", metadata)
|
|
279
|
-
|
|
280
|
-
response
|
|
281
|
-
ensure
|
|
282
|
-
span.finish
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# Extract metadata from chat instance (provider, model, tools, stream flag)
|
|
287
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
288
|
-
# @param stream [Boolean] whether this is a streaming request
|
|
289
|
-
# @return [Hash] metadata hash
|
|
290
|
-
def self.extract_metadata(chat, stream: false)
|
|
291
|
-
metadata = {"provider" => "ruby_llm"}
|
|
292
|
-
metadata["stream"] = true if stream
|
|
293
|
-
|
|
294
|
-
# Extract model
|
|
295
|
-
if chat.respond_to?(:model) && chat.model
|
|
296
|
-
model = chat.model.respond_to?(:id) ? chat.model.id : chat.model.to_s
|
|
297
|
-
metadata["model"] = model
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Extract tools (only for non-streaming)
|
|
301
|
-
if !stream && chat.respond_to?(:tools) && chat.tools&.any?
|
|
302
|
-
metadata["tools"] = extract_tools_metadata(chat)
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
metadata
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# Extract tools metadata from chat instance
|
|
309
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
310
|
-
# @return [Array<Hash>] array of tool schemas
|
|
311
|
-
def self.extract_tools_metadata(chat)
|
|
312
|
-
provider = chat.instance_variable_get(:@provider) if chat.instance_variable_defined?(:@provider)
|
|
313
|
-
|
|
314
|
-
chat.tools.map do |_name, tool|
|
|
315
|
-
format_tool_schema(tool, provider)
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
# Format a tool into OpenAI-compatible schema
|
|
320
|
-
# @param tool [Object] the tool object
|
|
321
|
-
# @param provider [Object, nil] the provider instance
|
|
322
|
-
# @return [Hash] tool schema
|
|
323
|
-
def self.format_tool_schema(tool, provider)
|
|
324
|
-
tool_schema = nil
|
|
325
|
-
|
|
326
|
-
# Use provider-specific tool_for method if available
|
|
327
|
-
if provider
|
|
328
|
-
begin
|
|
329
|
-
tool_schema = if provider.is_a?(::RubyLLM::Providers::OpenAI)
|
|
330
|
-
::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
|
|
331
|
-
elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
|
|
332
|
-
::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
|
|
333
|
-
elsif tool.respond_to?(:params_schema) && tool.params_schema
|
|
334
|
-
build_basic_tool_schema(tool)
|
|
335
|
-
else
|
|
336
|
-
build_minimal_tool_schema(tool)
|
|
337
|
-
end
|
|
338
|
-
rescue NameError, ArgumentError => e
|
|
339
|
-
# If provider-specific tool_for fails, fall back to basic format
|
|
340
|
-
Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
|
|
341
|
-
tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
|
|
342
|
-
end
|
|
343
|
-
else
|
|
344
|
-
# No provider, use basic format with params_schema if available
|
|
345
|
-
tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
# Strip RubyLLM-specific fields to match native OpenAI format
|
|
349
|
-
# Handle both symbol and string keys
|
|
350
|
-
function_key = tool_schema&.key?(:function) ? :function : "function"
|
|
351
|
-
if tool_schema && tool_schema[function_key]
|
|
352
|
-
tool_params = tool_schema[function_key][:parameters] || tool_schema[function_key]["parameters"]
|
|
353
|
-
if tool_params.is_a?(Hash)
|
|
354
|
-
# Create a mutable copy if the hash is frozen
|
|
355
|
-
tool_params = tool_params.dup if tool_params.frozen?
|
|
356
|
-
tool_params.delete("strict")
|
|
357
|
-
tool_params.delete(:strict)
|
|
358
|
-
tool_params.delete("additionalProperties")
|
|
359
|
-
tool_params.delete(:additionalProperties)
|
|
360
|
-
# Assign the modified copy back
|
|
361
|
-
params_key = tool_schema[function_key].key?(:parameters) ? :parameters : "parameters"
|
|
362
|
-
tool_schema[function_key][params_key] = tool_params
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
tool_schema
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# Build a basic tool schema with parameters
|
|
370
|
-
# @param tool [Object] the tool object
|
|
371
|
-
# @return [Hash] tool schema
|
|
372
|
-
def self.build_basic_tool_schema(tool)
|
|
373
|
-
{
|
|
374
|
-
"type" => "function",
|
|
375
|
-
"function" => {
|
|
376
|
-
"name" => tool.name.to_s,
|
|
377
|
-
"description" => tool.description,
|
|
378
|
-
"parameters" => tool.params_schema
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
# Build a minimal tool schema without parameters
|
|
384
|
-
# @param tool [Object] the tool object
|
|
385
|
-
# @return [Hash] tool schema
|
|
386
|
-
def self.build_minimal_tool_schema(tool)
|
|
387
|
-
{
|
|
388
|
-
"type" => "function",
|
|
389
|
-
"function" => {
|
|
390
|
-
"name" => tool.name.to_s,
|
|
391
|
-
"description" => tool.description,
|
|
392
|
-
"parameters" => {}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# Build input messages array from chat history and prompt
|
|
398
|
-
# Formats messages to match OpenAI's message format
|
|
399
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
400
|
-
# @param prompt [String, nil] the user prompt
|
|
401
|
-
# @return [Array<Hash>] array of message hashes
|
|
402
|
-
def self.build_input_messages(chat, prompt)
|
|
403
|
-
input_messages = []
|
|
404
|
-
|
|
405
|
-
# Add conversation history, formatting each message to OpenAI format
|
|
406
|
-
if chat.respond_to?(:messages) && chat.messages&.any?
|
|
407
|
-
input_messages = chat.messages.map { |m| format_message_for_input(m) }
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
# Add current prompt
|
|
411
|
-
input_messages << {"role" => "user", "content" => prompt} if prompt
|
|
412
|
-
|
|
413
|
-
input_messages
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
# Format a RubyLLM message to OpenAI-compatible format
|
|
417
|
-
# @param msg [Object] the RubyLLM message
|
|
418
|
-
# @return [Hash] OpenAI-formatted message
|
|
419
|
-
def self.format_message_for_input(msg)
|
|
420
|
-
formatted = {
|
|
421
|
-
"role" => msg.role.to_s
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
# Handle content
|
|
425
|
-
if msg.respond_to?(:content) && msg.content
|
|
426
|
-
raw_content = msg.content
|
|
427
|
-
|
|
428
|
-
# Check if content is a Content object with attachments (issue #71)
|
|
429
|
-
formatted["content"] = if raw_content.respond_to?(:text) && raw_content.respond_to?(:attachments) && raw_content.attachments&.any?
|
|
430
|
-
format_multipart_content(raw_content)
|
|
431
|
-
else
|
|
432
|
-
format_simple_content(raw_content, msg.role.to_s)
|
|
433
|
-
end
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# Handle tool_calls for assistant messages
|
|
437
|
-
if msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
|
|
438
|
-
formatted["tool_calls"] = format_tool_calls(msg.tool_calls)
|
|
439
|
-
formatted["content"] = nil
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Handle tool_call_id for tool result messages
|
|
443
|
-
if msg.respond_to?(:tool_call_id) && msg.tool_call_id
|
|
444
|
-
formatted["tool_call_id"] = msg.tool_call_id
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
formatted
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
# Format multipart content with text and attachments
|
|
451
|
-
# @param content_obj [Object] Content object with text and attachments
|
|
452
|
-
# @return [Array<Hash>] array of content parts
|
|
453
|
-
def self.format_multipart_content(content_obj)
|
|
454
|
-
content_parts = []
|
|
455
|
-
|
|
456
|
-
# Add text part
|
|
457
|
-
content_parts << {"type" => "text", "text" => content_obj.text} if content_obj.text
|
|
458
|
-
|
|
459
|
-
# Add attachment parts (convert to Braintrust format)
|
|
460
|
-
content_obj.attachments.each do |attachment|
|
|
461
|
-
content_parts << format_attachment_for_input(attachment)
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
content_parts
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
# Format simple text content
|
|
468
|
-
# @param raw_content [Object] String or Content object with text
|
|
469
|
-
# @param role [String] the message role
|
|
470
|
-
# @return [String] formatted text content
|
|
471
|
-
def self.format_simple_content(raw_content, role)
|
|
472
|
-
content = raw_content
|
|
473
|
-
content = content.text if content.respond_to?(:text)
|
|
474
|
-
|
|
475
|
-
# Convert Ruby hash string to JSON for tool results
|
|
476
|
-
if role == "tool" && content.is_a?(String) && content.start_with?("{:")
|
|
477
|
-
begin
|
|
478
|
-
content = content.gsub(/(?<=\{|, ):(\w+)=>/, '"\1":').gsub("=>", ":")
|
|
479
|
-
rescue
|
|
480
|
-
# Keep original if conversion fails
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
content
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
# Format a RubyLLM attachment to OpenAI-compatible format
|
|
488
|
-
# @param attachment [Object] the RubyLLM attachment
|
|
489
|
-
# @return [Hash] OpenAI image_url format for consistency with other integrations
|
|
490
|
-
def self.format_attachment_for_input(attachment)
|
|
491
|
-
# RubyLLM Attachment has: source (Pathname), filename, mime_type
|
|
492
|
-
if attachment.respond_to?(:source) && attachment.source
|
|
493
|
-
begin
|
|
494
|
-
data = File.binread(attachment.source.to_s)
|
|
495
|
-
encoded = Internal::Encoding::Base64.strict_encode64(data)
|
|
496
|
-
mime_type = attachment.respond_to?(:mime_type) ? attachment.mime_type : "application/octet-stream"
|
|
497
|
-
|
|
498
|
-
# Use OpenAI's image_url format for consistency
|
|
499
|
-
{
|
|
500
|
-
"type" => "image_url",
|
|
501
|
-
"image_url" => {
|
|
502
|
-
"url" => "data:#{mime_type};base64,#{encoded}"
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
rescue => e
|
|
506
|
-
Log.debug("Failed to read attachment file: #{e.message}")
|
|
507
|
-
# Return a placeholder if we can't read the file
|
|
508
|
-
{"type" => "text", "text" => "[attachment: #{attachment.respond_to?(:filename) ? attachment.filename : "unknown"}]"}
|
|
509
|
-
end
|
|
510
|
-
elsif attachment.respond_to?(:to_h)
|
|
511
|
-
# Try to use attachment's own serialization
|
|
512
|
-
attachment.to_h
|
|
513
|
-
else
|
|
514
|
-
{"type" => "text", "text" => "[attachment]"}
|
|
515
|
-
end
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
# Capture streaming output and metrics
|
|
519
|
-
# @param span [OpenTelemetry::Trace::Span] the span
|
|
520
|
-
# @param aggregated_chunks [Array] the aggregated chunks
|
|
521
|
-
# @param result [Object] the result object
|
|
522
|
-
def self.capture_streaming_output(span, aggregated_chunks, result)
|
|
523
|
-
return if aggregated_chunks.empty?
|
|
524
|
-
|
|
525
|
-
# Aggregate content from chunks
|
|
526
|
-
# Extract text from Content objects if present (issue #71)
|
|
527
|
-
aggregated_content = aggregated_chunks.map { |c|
|
|
528
|
-
content = c.respond_to?(:content) ? c.content : c.to_s
|
|
529
|
-
content = content.text if content.respond_to?(:text)
|
|
530
|
-
content
|
|
531
|
-
}.join
|
|
532
|
-
|
|
533
|
-
output = [{
|
|
534
|
-
role: "assistant",
|
|
535
|
-
content: aggregated_content
|
|
536
|
-
}]
|
|
537
|
-
set_json_attr(span, "braintrust.output_json", output)
|
|
538
|
-
|
|
539
|
-
# Try to extract usage from the result
|
|
540
|
-
if result.respond_to?(:usage) && result.usage
|
|
541
|
-
metrics = parse_usage_tokens(result.usage)
|
|
542
|
-
set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
543
|
-
end
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# Capture non-streaming output and metrics
|
|
547
|
-
# @param span [OpenTelemetry::Trace::Span] the span
|
|
548
|
-
# @param chat [RubyLLM::Chat] the chat instance
|
|
549
|
-
# @param response [Object] the response object
|
|
550
|
-
# @param messages_before_count [Integer] message count before the call
|
|
551
|
-
def self.capture_non_streaming_output(span, chat, response, messages_before_count)
|
|
552
|
-
return unless response
|
|
553
|
-
|
|
554
|
-
# Build message object from response
|
|
555
|
-
message = {
|
|
556
|
-
"role" => "assistant",
|
|
557
|
-
"content" => nil
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
# Add content if it's a simple text response
|
|
561
|
-
# Extract text from Content objects if present (issue #71)
|
|
562
|
-
if response.respond_to?(:content) && response.content && !response.content.empty?
|
|
563
|
-
content = response.content
|
|
564
|
-
content = content.text if content.respond_to?(:text)
|
|
565
|
-
message["content"] = content
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
# Check if there are tool calls in the messages history
|
|
569
|
-
# Look at messages added during this complete() call
|
|
570
|
-
if chat.respond_to?(:messages) && chat.messages
|
|
571
|
-
assistant_msg = chat.messages[messages_before_count..].find { |m|
|
|
572
|
-
m.role.to_s == "assistant" && m.respond_to?(:tool_calls) && m.tool_calls&.any?
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
if assistant_msg&.tool_calls&.any?
|
|
576
|
-
message["tool_calls"] = format_tool_calls(assistant_msg.tool_calls)
|
|
577
|
-
message["content"] = nil
|
|
578
|
-
end
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
# Format as OpenAI choices[] structure
|
|
582
|
-
output = [{
|
|
583
|
-
"index" => 0,
|
|
584
|
-
"message" => message,
|
|
585
|
-
"finish_reason" => message["tool_calls"] ? "tool_calls" : "stop"
|
|
586
|
-
}]
|
|
587
|
-
|
|
588
|
-
set_json_attr(span, "braintrust.output_json", output)
|
|
589
|
-
|
|
590
|
-
# Set metrics (token usage)
|
|
591
|
-
if response.respond_to?(:to_h)
|
|
592
|
-
response_hash = response.to_h
|
|
593
|
-
usage = {
|
|
594
|
-
"input_tokens" => response_hash[:input_tokens],
|
|
595
|
-
"output_tokens" => response_hash[:output_tokens],
|
|
596
|
-
"cached_tokens" => response_hash[:cached_tokens],
|
|
597
|
-
"cache_creation_tokens" => response_hash[:cache_creation_tokens]
|
|
598
|
-
}.compact
|
|
599
|
-
|
|
600
|
-
unless usage.empty?
|
|
601
|
-
metrics = parse_usage_tokens(usage)
|
|
602
|
-
set_json_attr(span, "braintrust.metrics", metrics) unless metrics.empty?
|
|
603
|
-
end
|
|
604
|
-
end
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
# Format tool calls into OpenAI format
|
|
608
|
-
# @param tool_calls [Hash, Array] the tool calls
|
|
609
|
-
# @return [Array<Hash>] formatted tool calls
|
|
610
|
-
def self.format_tool_calls(tool_calls)
|
|
611
|
-
tool_calls.map do |_id, tc|
|
|
612
|
-
# Ensure arguments is a JSON string (OpenAI format)
|
|
613
|
-
args = tc.arguments
|
|
614
|
-
args_string = args.is_a?(String) ? args : JSON.generate(args)
|
|
615
|
-
|
|
616
|
-
{
|
|
617
|
-
"id" => tc.id,
|
|
618
|
-
"type" => "function",
|
|
619
|
-
"function" => {
|
|
620
|
-
"name" => tc.name,
|
|
621
|
-
"arguments" => args_string
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
end
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
end
|
|
628
|
-
end
|
|
629
|
-
end
|
|
630
|
-
end
|
|
631
|
-
end
|