braintrust 0.0.5 → 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 +3 -52
- data/lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb +110 -116
- data/lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb +447 -0
- data/lib/braintrust/trace/contrib/openai.rb +3 -63
- data/lib/braintrust/trace/tokens.rb +101 -0
- data/lib/braintrust/trace.rb +17 -0
- data/lib/braintrust/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 866cb2e797502f00cda1625ad90f4d734b4b83f0d21d8243675a933fae9df693
|
|
4
|
+
data.tar.gz: f74151b0e18b12cf19b61b1b75b2f58e784d4171f21c0996526d29c719174260
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad2f68a6de8d547b6a609c3393522c4ae3dfcb441a9fc841484bbbcb21de7648da7a00cd625612d98c6b99e4ad41186a2bc3fff706e17b9797e7ac514e685923
|
|
7
|
+
data.tar.gz: f0613e5fa08c07333c74467ec7830a40f72905475e35becf7a2add077168c7554046aa9a3824fe24006870338163526e8d170cfd25727af5d53416283ae03714
|
data/README.md
CHANGED
|
@@ -155,7 +155,7 @@ message = tracer.in_span("chat-message") do |span|
|
|
|
155
155
|
root_span = span
|
|
156
156
|
|
|
157
157
|
client.messages.create(
|
|
158
|
-
model: "claude-3-
|
|
158
|
+
model: "claude-3-haiku-20240307",
|
|
159
159
|
max_tokens: 100,
|
|
160
160
|
system: "You are a helpful assistant.",
|
|
161
161
|
messages: [
|
|
@@ -171,6 +171,34 @@ puts "View trace at: #{Braintrust::Trace.permalink(root_span)}"
|
|
|
171
171
|
OpenTelemetry.tracer_provider.shutdown
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
### RubyLLM Tracing
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
require "braintrust"
|
|
178
|
+
require "ruby_llm"
|
|
179
|
+
|
|
180
|
+
Braintrust.init
|
|
181
|
+
|
|
182
|
+
# Wrap RubyLLM globally (wraps all Chat instances)
|
|
183
|
+
Braintrust::Trace::Contrib::Github::Crmne::RubyLLM.wrap
|
|
184
|
+
|
|
185
|
+
tracer = OpenTelemetry.tracer_provider.tracer("ruby-llm-app")
|
|
186
|
+
root_span = nil
|
|
187
|
+
|
|
188
|
+
response = tracer.in_span("chat") do |span|
|
|
189
|
+
root_span = span
|
|
190
|
+
|
|
191
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
192
|
+
chat.ask("Say hello!")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
puts "Response: #{response.content}"
|
|
196
|
+
|
|
197
|
+
puts "View trace at: #{Braintrust::Trace.permalink(root_span)}"
|
|
198
|
+
|
|
199
|
+
OpenTelemetry.tracer_provider.shutdown
|
|
200
|
+
```
|
|
201
|
+
|
|
174
202
|
### Attachments
|
|
175
203
|
|
|
176
204
|
Attachments allow you to log binary data (images, PDFs, audio, etc.) as part of your traces. This is particularly useful for multimodal AI applications like vision models.
|
|
@@ -236,7 +264,9 @@ Check out the [`examples/`](./examples/) directory for complete working examples
|
|
|
236
264
|
- [eval.rb](./examples/eval.rb) - Create and run evaluations with custom test cases and scoring functions
|
|
237
265
|
- [trace.rb](./examples/trace.rb) - Manual span creation and tracing
|
|
238
266
|
- [openai.rb](./examples/openai.rb) - Automatically trace OpenAI API calls
|
|
267
|
+
- [alexrudall_openai.rb](./examples/alexrudall_openai.rb) - Automatically trace ruby-openai gem API calls
|
|
239
268
|
- [anthropic.rb](./examples/anthropic.rb) - Automatically trace Anthropic API calls
|
|
269
|
+
- [ruby_llm.rb](./examples/ruby_llm.rb) - Automatically trace RubyLLM API calls
|
|
240
270
|
- [trace/trace_attachments.rb](./examples/trace/trace_attachments.rb) - Log attachments (images, PDFs) in traces
|
|
241
271
|
- [eval/dataset.rb](./examples/eval/dataset.rb) - Run evaluations using datasets stored in Braintrust
|
|
242
272
|
- [eval/remote_functions.rb](./examples/eval/remote_functions.rb) - Use remote scoring functions
|
data/lib/braintrust/state.rb
CHANGED
|
@@ -49,6 +49,20 @@ module Braintrust
|
|
|
49
49
|
)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Create a State object directly with explicit parameters
|
|
53
|
+
# @param api_key [String] Braintrust API key (required)
|
|
54
|
+
# @param org_name [String, nil] Organization name
|
|
55
|
+
# @param org_id [String, nil] Organization ID (if provided, skips login - useful for testing)
|
|
56
|
+
# @param default_project [String, nil] Default project name
|
|
57
|
+
# @param app_url [String, nil] App URL (default: https://www.braintrust.dev)
|
|
58
|
+
# @param api_url [String, nil] API URL
|
|
59
|
+
# @param proxy_url [String, nil] Proxy URL
|
|
60
|
+
# @param blocking_login [Boolean] Login synchronously (default: false)
|
|
61
|
+
# @param enable_tracing [Boolean] Enable OpenTelemetry tracing (default: true)
|
|
62
|
+
# @param tracer_provider [TracerProvider, nil] Optional tracer provider
|
|
63
|
+
# @param config [Config, nil] Optional config object
|
|
64
|
+
# @param exporter [Exporter, nil] Optional exporter for testing
|
|
65
|
+
# @return [State] the created state
|
|
52
66
|
def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, config: nil, exporter: nil)
|
|
53
67
|
# Instance-level mutex for thread-safe login
|
|
54
68
|
@login_mutex = Mutex.new
|
|
@@ -61,13 +75,17 @@ module Braintrust
|
|
|
61
75
|
@app_url = app_url || "https://www.braintrust.dev"
|
|
62
76
|
@api_url = api_url
|
|
63
77
|
@proxy_url = proxy_url
|
|
64
|
-
@logged_in = false
|
|
65
78
|
@config = config
|
|
66
79
|
|
|
67
|
-
#
|
|
68
|
-
|
|
80
|
+
# If org_id is provided, we're already "logged in" (useful for testing)
|
|
81
|
+
# Otherwise, perform login to discover org info
|
|
82
|
+
if org_id
|
|
83
|
+
@logged_in = true
|
|
84
|
+
elsif blocking_login
|
|
85
|
+
@logged_in = false
|
|
69
86
|
login
|
|
70
87
|
else
|
|
88
|
+
@logged_in = false
|
|
71
89
|
login_in_thread
|
|
72
90
|
end
|
|
73
91
|
|
|
@@ -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,61 +18,11 @@ module Braintrust
|
|
|
17
18
|
span.set_attribute(attr_name, JSON.generate(obj))
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
# Parse usage tokens from Anthropic API response
|
|
21
|
-
# Maps Anthropic field names to Braintrust standard names:
|
|
22
|
-
# - input_tokens → contributes to prompt_tokens
|
|
23
|
-
# - cache_creation_input_tokens → prompt_cache_creation_tokens (and adds to prompt_tokens)
|
|
24
|
-
# - cache_read_input_tokens → prompt_cached_tokens (and adds to prompt_tokens)
|
|
25
|
-
# - output_tokens → completion_tokens
|
|
26
|
-
# - total_tokens → tokens (or calculated if missing)
|
|
27
|
-
#
|
|
21
|
+
# Parse usage tokens from Anthropic API response
|
|
28
22
|
# @param usage [Hash, Object] usage object from Anthropic response
|
|
29
23
|
# @return [Hash<String, Integer>] metrics hash with normalized names
|
|
30
24
|
def self.parse_usage_tokens(usage)
|
|
31
|
-
|
|
32
|
-
return metrics unless usage
|
|
33
|
-
|
|
34
|
-
# Convert to hash if it's an object
|
|
35
|
-
usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
|
|
36
|
-
|
|
37
|
-
# Extract base values for calculation
|
|
38
|
-
input_tokens = 0
|
|
39
|
-
cache_creation_tokens = 0
|
|
40
|
-
cache_read_tokens = 0
|
|
41
|
-
|
|
42
|
-
usage_hash.each do |key, value|
|
|
43
|
-
next unless value.is_a?(Numeric)
|
|
44
|
-
key_str = key.to_s
|
|
45
|
-
|
|
46
|
-
case key_str
|
|
47
|
-
when "input_tokens"
|
|
48
|
-
input_tokens = value.to_i
|
|
49
|
-
when "cache_creation_input_tokens"
|
|
50
|
-
cache_creation_tokens = value.to_i
|
|
51
|
-
metrics["prompt_cache_creation_tokens"] = value.to_i
|
|
52
|
-
when "cache_read_input_tokens"
|
|
53
|
-
cache_read_tokens = value.to_i
|
|
54
|
-
metrics["prompt_cached_tokens"] = value.to_i
|
|
55
|
-
when "output_tokens"
|
|
56
|
-
metrics["completion_tokens"] = value.to_i
|
|
57
|
-
when "total_tokens"
|
|
58
|
-
metrics["tokens"] = value.to_i
|
|
59
|
-
else
|
|
60
|
-
# Keep other numeric fields as-is (future-proofing)
|
|
61
|
-
metrics[key_str] = value.to_i
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Calculate total prompt tokens (input + cache creation + cache read)
|
|
66
|
-
total_prompt_tokens = input_tokens + cache_creation_tokens + cache_read_tokens
|
|
67
|
-
metrics["prompt_tokens"] = total_prompt_tokens
|
|
68
|
-
|
|
69
|
-
# Calculate total tokens if not provided by Anthropic
|
|
70
|
-
if !metrics.key?("tokens") && metrics.key?("completion_tokens")
|
|
71
|
-
metrics["tokens"] = total_prompt_tokens + metrics["completion_tokens"]
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
metrics
|
|
25
|
+
Braintrust::Trace.parse_anthropic_usage_tokens(usage)
|
|
75
26
|
end
|
|
76
27
|
|
|
77
28
|
# Wrap an Anthropic::Client to automatically create spans for messages and responses
|
|
@@ -2,140 +2,134 @@
|
|
|
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
|
|
8
|
-
module
|
|
9
|
-
module
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
module Contrib
|
|
10
|
+
module Github
|
|
11
|
+
module Alexrudall
|
|
12
|
+
module RubyOpenAI
|
|
13
|
+
# Helper to safely set a JSON attribute on a span
|
|
14
|
+
# Only sets the attribute if obj is present
|
|
15
|
+
# @param span [OpenTelemetry::Trace::Span] the span to set attribute on
|
|
16
|
+
# @param attr_name [String] the attribute name (e.g., "braintrust.output_json")
|
|
17
|
+
# @param obj [Object] the object to serialize to JSON
|
|
18
|
+
# @return [void]
|
|
19
|
+
def self.set_json_attr(span, attr_name, obj)
|
|
20
|
+
return unless obj
|
|
21
|
+
span.set_attribute(attr_name, JSON.generate(obj))
|
|
22
|
+
end
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# @param usage [Hash] usage hash from OpenAI response
|
|
28
|
-
# @return [Hash<String, Integer>] metrics hash with normalized names
|
|
29
|
-
def self.parse_usage_tokens(usage)
|
|
30
|
-
metrics = {}
|
|
31
|
-
return metrics unless usage
|
|
32
|
-
|
|
33
|
-
# Basic token counts
|
|
34
|
-
metrics["prompt_tokens"] = usage["prompt_tokens"].to_i if usage["prompt_tokens"]
|
|
35
|
-
metrics["completion_tokens"] = usage["completion_tokens"].to_i if usage["completion_tokens"]
|
|
36
|
-
metrics["total_tokens"] = usage["total_tokens"].to_i if usage["total_tokens"]
|
|
37
|
-
|
|
38
|
-
# Rename total_tokens to tokens for consistency
|
|
39
|
-
metrics["tokens"] = metrics.delete("total_tokens") if metrics["total_tokens"]
|
|
40
|
-
|
|
41
|
-
metrics
|
|
42
|
-
end
|
|
24
|
+
# Parse usage tokens from OpenAI API response
|
|
25
|
+
# @param usage [Hash] usage hash from OpenAI response
|
|
26
|
+
# @return [Hash<String, Integer>] metrics hash with normalized names
|
|
27
|
+
def self.parse_usage_tokens(usage)
|
|
28
|
+
Braintrust::Trace.parse_openai_usage_tokens(usage)
|
|
29
|
+
end
|
|
43
30
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
31
|
+
# Wrap an OpenAI::Client (ruby-openai gem) to automatically create spans
|
|
32
|
+
# Supports both synchronous and streaming requests
|
|
33
|
+
# @param client [OpenAI::Client] the OpenAI client to wrap
|
|
34
|
+
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider (defaults to global)
|
|
35
|
+
def self.wrap(client, tracer_provider: nil)
|
|
36
|
+
tracer_provider ||= ::OpenTelemetry.tracer_provider
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
38
|
+
# Wrap chat completions
|
|
39
|
+
wrap_chat(client, tracer_provider)
|
|
53
40
|
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
client
|
|
42
|
+
end
|
|
56
43
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
44
|
+
# Wrap chat API
|
|
45
|
+
# @param client [OpenAI::Client] the OpenAI client
|
|
46
|
+
# @param tracer_provider [OpenTelemetry::SDK::Trace::TracerProvider] the tracer provider
|
|
47
|
+
def self.wrap_chat(client, tracer_provider)
|
|
48
|
+
# Create a wrapper module that intercepts the chat method
|
|
49
|
+
wrapper = Module.new do
|
|
50
|
+
define_method(:chat) do |parameters:|
|
|
51
|
+
tracer = tracer_provider.tracer("braintrust")
|
|
52
|
+
|
|
53
|
+
tracer.in_span("openai.chat.completions.create") do |span|
|
|
54
|
+
# Initialize metadata hash
|
|
55
|
+
metadata = {
|
|
56
|
+
"provider" => "openai",
|
|
57
|
+
"endpoint" => "/v1/chat/completions"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Capture request metadata fields
|
|
61
|
+
metadata_fields = %w[
|
|
62
|
+
model frequency_penalty logit_bias logprobs max_tokens n
|
|
63
|
+
presence_penalty response_format seed service_tier stop
|
|
64
|
+
stream stream_options temperature top_p top_logprobs
|
|
65
|
+
tools tool_choice parallel_tool_calls user functions function_call
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
metadata_fields.each do |field|
|
|
69
|
+
field_sym = field.to_sym
|
|
70
|
+
if parameters.key?(field_sym)
|
|
71
|
+
# Special handling for stream parameter (it's a Proc)
|
|
72
|
+
metadata[field] = if field == "stream"
|
|
73
|
+
true # Just mark as streaming
|
|
74
|
+
else
|
|
75
|
+
parameters[field_sym]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
89
78
|
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Set input messages as JSON
|
|
94
|
-
if parameters[:messages]
|
|
95
|
-
span.set_attribute("braintrust.input_json", JSON.generate(parameters[:messages]))
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
begin
|
|
99
|
-
# Call the original method
|
|
100
|
-
response = super(parameters: parameters)
|
|
101
79
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Set metrics (token usage)
|
|
108
|
-
if response && response["usage"]
|
|
109
|
-
metrics = Braintrust::Trace::AlexRudall::RubyOpenAI.parse_usage_tokens(response["usage"])
|
|
110
|
-
span.set_attribute("braintrust.metrics", JSON.generate(metrics)) unless metrics.empty?
|
|
111
|
-
end
|
|
80
|
+
# Set input messages as JSON
|
|
81
|
+
if parameters[:messages]
|
|
82
|
+
span.set_attribute("braintrust.input_json", JSON.generate(parameters[:messages]))
|
|
83
|
+
end
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
85
|
+
begin
|
|
86
|
+
# Call the original method
|
|
87
|
+
response = super(parameters: parameters)
|
|
88
|
+
|
|
89
|
+
# Set output (choices) as JSON
|
|
90
|
+
if response && response["choices"]&.any?
|
|
91
|
+
span.set_attribute("braintrust.output_json", JSON.generate(response["choices"]))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Set metrics (token usage)
|
|
95
|
+
if response && response["usage"]
|
|
96
|
+
metrics = Braintrust::Trace::Contrib::Github::Alexrudall::RubyOpenAI.parse_usage_tokens(response["usage"])
|
|
97
|
+
span.set_attribute("braintrust.metrics", JSON.generate(metrics)) unless metrics.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Add response metadata fields
|
|
101
|
+
if response
|
|
102
|
+
metadata["id"] = response["id"] if response["id"]
|
|
103
|
+
metadata["created"] = response["created"] if response["created"]
|
|
104
|
+
metadata["system_fingerprint"] = response["system_fingerprint"] if response["system_fingerprint"]
|
|
105
|
+
metadata["service_tier"] = response["service_tier"] if response["service_tier"]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Set metadata ONCE at the end with complete hash
|
|
109
|
+
span.set_attribute("braintrust.metadata", JSON.generate(metadata))
|
|
110
|
+
|
|
111
|
+
response
|
|
112
|
+
rescue => e
|
|
113
|
+
# Record exception in span
|
|
114
|
+
span.record_exception(e)
|
|
115
|
+
span.status = OpenTelemetry::Trace::Status.error("Exception: #{e.class} - #{e.message}")
|
|
116
|
+
raise
|
|
117
|
+
end
|
|
119
118
|
end
|
|
120
|
-
|
|
121
|
-
# Set metadata ONCE at the end with complete hash
|
|
122
|
-
span.set_attribute("braintrust.metadata", JSON.generate(metadata))
|
|
123
|
-
|
|
124
|
-
response
|
|
125
|
-
rescue => e
|
|
126
|
-
# Record exception in span
|
|
127
|
-
span.record_exception(e)
|
|
128
|
-
span.status = OpenTelemetry::Trace::Status.error("Exception: #{e.class} - #{e.message}")
|
|
129
|
-
raise
|
|
130
119
|
end
|
|
131
120
|
end
|
|
121
|
+
|
|
122
|
+
# Prepend the wrapper to the client's singleton class
|
|
123
|
+
client.singleton_class.prepend(wrapper)
|
|
132
124
|
end
|
|
133
125
|
end
|
|
134
|
-
|
|
135
|
-
# Prepend the wrapper to the client's singleton class
|
|
136
|
-
client.singleton_class.prepend(wrapper)
|
|
137
126
|
end
|
|
138
127
|
end
|
|
139
128
|
end
|
|
129
|
+
|
|
130
|
+
# Backwards compatibility: this module was originally at Braintrust::Trace::AlexRudall::RubyOpenAI
|
|
131
|
+
module AlexRudall
|
|
132
|
+
RubyOpenAI = Contrib::Github::Alexrudall::RubyOpenAI
|
|
133
|
+
end
|
|
140
134
|
end
|
|
141
135
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Braintrust
|
|
4
|
+
module Trace
|
|
5
|
+
# Parse OpenAI usage tokens into normalized Braintrust metrics.
|
|
6
|
+
# Handles standard fields and *_tokens_details nested objects.
|
|
7
|
+
# @param usage [Hash, Object] usage object from OpenAI response
|
|
8
|
+
# @return [Hash<String, Integer>] normalized metrics
|
|
9
|
+
def self.parse_openai_usage_tokens(usage)
|
|
10
|
+
metrics = {}
|
|
11
|
+
return metrics unless usage
|
|
12
|
+
|
|
13
|
+
usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
|
|
14
|
+
return metrics unless usage_hash.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
# Field mappings: OpenAI → Braintrust
|
|
17
|
+
field_map = {
|
|
18
|
+
"prompt_tokens" => "prompt_tokens",
|
|
19
|
+
"completion_tokens" => "completion_tokens",
|
|
20
|
+
"total_tokens" => "tokens"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Prefix mappings for *_tokens_details
|
|
24
|
+
prefix_map = {
|
|
25
|
+
"prompt" => "prompt",
|
|
26
|
+
"completion" => "completion"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
usage_hash.each do |key, value|
|
|
30
|
+
key_str = key.to_s
|
|
31
|
+
|
|
32
|
+
if value.is_a?(Numeric)
|
|
33
|
+
target = field_map[key_str]
|
|
34
|
+
metrics[target] = value.to_i if target
|
|
35
|
+
elsif key_str.end_with?("_tokens_details")
|
|
36
|
+
# Convert to hash if it's an object (OpenAI SDK returns objects)
|
|
37
|
+
details_hash = value.respond_to?(:to_h) ? value.to_h : value
|
|
38
|
+
next unless details_hash.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
raw_prefix = key_str.sub(/_tokens_details$/, "")
|
|
41
|
+
prefix = prefix_map[raw_prefix] || raw_prefix
|
|
42
|
+
details_hash.each do |detail_key, detail_value|
|
|
43
|
+
next unless detail_value.is_a?(Numeric)
|
|
44
|
+
metrics["#{prefix}_#{detail_key}"] = detail_value.to_i
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Calculate total if missing
|
|
50
|
+
if !metrics.key?("tokens") && metrics.key?("prompt_tokens") && metrics.key?("completion_tokens")
|
|
51
|
+
metrics["tokens"] = metrics["prompt_tokens"] + metrics["completion_tokens"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
metrics
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parse Anthropic usage tokens into normalized Braintrust metrics.
|
|
58
|
+
# Accumulates cache tokens into prompt_tokens and calculates total.
|
|
59
|
+
# @param usage [Hash, Object] usage object from Anthropic response
|
|
60
|
+
# @return [Hash<String, Integer>] normalized metrics
|
|
61
|
+
def self.parse_anthropic_usage_tokens(usage)
|
|
62
|
+
metrics = {}
|
|
63
|
+
return metrics unless usage
|
|
64
|
+
|
|
65
|
+
usage_hash = usage.respond_to?(:to_h) ? usage.to_h : usage
|
|
66
|
+
return metrics unless usage_hash.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
# Field mappings: Anthropic → Braintrust
|
|
69
|
+
# Also handles RubyLLM's simplified cache field names
|
|
70
|
+
field_map = {
|
|
71
|
+
"input_tokens" => "prompt_tokens",
|
|
72
|
+
"output_tokens" => "completion_tokens",
|
|
73
|
+
"cache_read_input_tokens" => "prompt_cached_tokens",
|
|
74
|
+
"cache_creation_input_tokens" => "prompt_cache_creation_tokens",
|
|
75
|
+
# RubyLLM uses simplified names
|
|
76
|
+
"cached_tokens" => "prompt_cached_tokens",
|
|
77
|
+
"cache_creation_tokens" => "prompt_cache_creation_tokens"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
usage_hash.each do |key, value|
|
|
81
|
+
next unless value.is_a?(Numeric)
|
|
82
|
+
key_str = key.to_s
|
|
83
|
+
target = field_map[key_str]
|
|
84
|
+
metrics[target] = value.to_i if target
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Accumulate cache tokens into prompt_tokens (matching TS/Python SDKs)
|
|
88
|
+
prompt_tokens = (metrics["prompt_tokens"] || 0) +
|
|
89
|
+
(metrics["prompt_cached_tokens"] || 0) +
|
|
90
|
+
(metrics["prompt_cache_creation_tokens"] || 0)
|
|
91
|
+
metrics["prompt_tokens"] = prompt_tokens if prompt_tokens > 0
|
|
92
|
+
|
|
93
|
+
# Calculate total
|
|
94
|
+
if metrics.key?("prompt_tokens") && metrics.key?("completion_tokens")
|
|
95
|
+
metrics["tokens"] = metrics["prompt_tokens"] + metrics["completion_tokens"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
metrics
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/braintrust/trace.rb
CHANGED
|
@@ -40,6 +40,23 @@ rescue LoadError
|
|
|
40
40
|
# Anthropic gem not installed - integration will not be available
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# RubyLLM integration is optional - automatically loaded if ruby_llm gem is available
|
|
44
|
+
#
|
|
45
|
+
# Usage:
|
|
46
|
+
# # Wrap the class once (affects all instances):
|
|
47
|
+
# Braintrust::Trace::RubyLLM.wrap
|
|
48
|
+
#
|
|
49
|
+
# # Or wrap a specific instance:
|
|
50
|
+
# chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
51
|
+
# Braintrust::Trace::RubyLLM.wrap(chat)
|
|
52
|
+
#
|
|
53
|
+
begin
|
|
54
|
+
require "ruby_llm"
|
|
55
|
+
require_relative "trace/contrib/github.com/crmne/ruby_llm"
|
|
56
|
+
rescue LoadError
|
|
57
|
+
# RubyLLM gem not installed - integration will not be available
|
|
58
|
+
end
|
|
59
|
+
|
|
43
60
|
module Braintrust
|
|
44
61
|
module Trace
|
|
45
62
|
# Set up OpenTelemetry tracing with Braintrust
|
data/lib/braintrust/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: braintrust
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Braintrust
|
|
@@ -205,9 +205,11 @@ files:
|
|
|
205
205
|
- lib/braintrust/trace/attachment.rb
|
|
206
206
|
- lib/braintrust/trace/contrib/anthropic.rb
|
|
207
207
|
- lib/braintrust/trace/contrib/github.com/alexrudall/ruby-openai/ruby-openai.rb
|
|
208
|
+
- lib/braintrust/trace/contrib/github.com/crmne/ruby_llm.rb
|
|
208
209
|
- lib/braintrust/trace/contrib/openai.rb
|
|
209
210
|
- lib/braintrust/trace/span_filter.rb
|
|
210
211
|
- lib/braintrust/trace/span_processor.rb
|
|
212
|
+
- lib/braintrust/trace/tokens.rb
|
|
211
213
|
- lib/braintrust/version.rb
|
|
212
214
|
homepage: https://github.com/braintrustdata/braintrust-sdk-ruby
|
|
213
215
|
licenses:
|