braintrust 0.1.0 → 0.1.2

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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/lib/braintrust/api/functions.rb +11 -0
  4. data/lib/braintrust/contrib/anthropic/instrumentation/beta_messages.rb +242 -0
  5. data/lib/braintrust/contrib/anthropic/integration.rb +2 -2
  6. data/lib/braintrust/contrib/anthropic/patcher.rb +83 -0
  7. data/lib/braintrust/contrib/openai/instrumentation/moderations.rb +93 -0
  8. data/lib/braintrust/contrib/openai/integration.rb +1 -1
  9. data/lib/braintrust/contrib/openai/patcher.rb +43 -0
  10. data/lib/braintrust/contrib/ruby_openai/instrumentation/moderations.rb +94 -0
  11. data/lib/braintrust/contrib/ruby_openai/integration.rb +1 -1
  12. data/lib/braintrust/contrib/ruby_openai/patcher.rb +35 -0
  13. data/lib/braintrust/internal/env.rb +6 -0
  14. data/lib/braintrust/internal/template.rb +91 -0
  15. data/lib/braintrust/prompt.rb +143 -0
  16. data/lib/braintrust/state.rb +1 -1
  17. data/lib/braintrust/trace.rb +41 -0
  18. data/lib/braintrust/vendor/mustache/context.rb +180 -0
  19. data/lib/braintrust/vendor/mustache/context_miss.rb +22 -0
  20. data/lib/braintrust/vendor/mustache/enumerable.rb +14 -0
  21. data/lib/braintrust/vendor/mustache/generator.rb +188 -0
  22. data/lib/braintrust/vendor/mustache/mustache.rb +260 -0
  23. data/lib/braintrust/vendor/mustache/parser.rb +364 -0
  24. data/lib/braintrust/vendor/mustache/settings.rb +252 -0
  25. data/lib/braintrust/vendor/mustache/template.rb +138 -0
  26. data/lib/braintrust/vendor/mustache/utils.rb +42 -0
  27. data/lib/braintrust/vendor/mustache.rb +16 -0
  28. data/lib/braintrust/version.rb +1 -1
  29. data/lib/braintrust.rb +1 -0
  30. metadata +16 -1
@@ -3,6 +3,7 @@
3
3
  require_relative "../patcher"
4
4
  require_relative "instrumentation/chat"
5
5
  require_relative "instrumentation/responses"
6
+ require_relative "instrumentation/moderations"
6
7
 
7
8
  module Braintrust
8
9
  module Contrib
@@ -80,6 +81,40 @@ module Braintrust
80
81
  end
81
82
  end
82
83
  end
84
+
85
+ # Patcher for ruby-openai moderations API.
86
+ # Instruments OpenAI::Client#moderations method.
87
+ class ModerationsPatcher < Braintrust::Contrib::Patcher
88
+ class << self
89
+ def applicable?
90
+ defined?(::OpenAI::Client) && ::OpenAI::Client.method_defined?(:moderations)
91
+ end
92
+
93
+ def patched?(**options)
94
+ target_class = options[:target]&.singleton_class || ::OpenAI::Client
95
+ Instrumentation::Moderations.applied?(target_class)
96
+ end
97
+
98
+ # Perform the actual patching.
99
+ # @param options [Hash] Configuration options passed from integration
100
+ # @option options [Object] :target Optional target instance to patch
101
+ # @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
102
+ # @return [void]
103
+ def perform_patch(**options)
104
+ return unless applicable?
105
+
106
+ if options[:target]
107
+ # Instance-level (for only this client)
108
+ raise ArgumentError, "target must be a kind of ::OpenAI::Client" unless options[:target].is_a?(::OpenAI::Client)
109
+
110
+ options[:target].singleton_class.include(Instrumentation::Moderations)
111
+ else
112
+ # Class-level (for all clients)
113
+ ::OpenAI::Client.include(Instrumentation::Moderations)
114
+ end
115
+ end
116
+ end
117
+ end
83
118
  end
84
119
  end
85
120
  end
@@ -7,11 +7,17 @@ module Braintrust
7
7
  ENV_AUTO_INSTRUMENT = "BRAINTRUST_AUTO_INSTRUMENT"
8
8
  ENV_INSTRUMENT_EXCEPT = "BRAINTRUST_INSTRUMENT_EXCEPT"
9
9
  ENV_INSTRUMENT_ONLY = "BRAINTRUST_INSTRUMENT_ONLY"
10
+ ENV_FLUSH_ON_EXIT = "BRAINTRUST_FLUSH_ON_EXIT"
10
11
 
11
12
  def self.auto_instrument
12
13
  ENV[ENV_AUTO_INSTRUMENT] != "false"
13
14
  end
14
15
 
16
+ # Whether to automatically flush spans on program exit. Default: true
17
+ def self.flush_on_exit
18
+ ENV[ENV_FLUSH_ON_EXIT] != "false"
19
+ end
20
+
15
21
  def self.instrument_except
16
22
  parse_list(ENV_INSTRUMENT_EXCEPT)
17
23
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../vendor/mustache"
4
+
5
+ module Braintrust
6
+ module Internal
7
+ # Template rendering utilities for Mustache templates
8
+ module Template
9
+ module_function
10
+
11
+ # Render a template string with variable substitution
12
+ #
13
+ # @param text [String] Template text to render
14
+ # @param variables [Hash] Variables to substitute
15
+ # @param format [String] Template format: "mustache", "none", or "nunjucks"
16
+ # @param strict [Boolean] Raise error on missing variables (default: false)
17
+ # @return [String] Rendered text
18
+ def render(text, variables, format:, strict: false)
19
+ return text unless text.is_a?(String)
20
+
21
+ case format
22
+ when "none"
23
+ # No templating - return text unchanged
24
+ text
25
+ when "nunjucks"
26
+ # Nunjucks is a UI-only feature in Braintrust
27
+ raise Error, "Nunjucks templates are not supported in the Ruby SDK. " \
28
+ "Nunjucks only works in Braintrust playgrounds. " \
29
+ "Please use 'mustache' or 'none' template format, or invoke the prompt via the API proxy."
30
+ when "mustache", "", nil
31
+ # Default: Mustache templating
32
+ if strict
33
+ missing = find_missing_variables(text, variables)
34
+ if missing.any?
35
+ raise Error, "Missing required variables: #{missing.join(", ")}"
36
+ end
37
+ end
38
+
39
+ Vendor::Mustache.render(text, variables)
40
+ else
41
+ raise Error, "Unknown template format: #{format.inspect}. " \
42
+ "Supported formats are 'mustache' and 'none'."
43
+ end
44
+ end
45
+
46
+ # Find Mustache variables in template that are not provided
47
+ #
48
+ # @param text [String] Template text
49
+ # @param variables [Hash] Available variables
50
+ # @return [Array<String>] List of missing variable names
51
+ def find_missing_variables(text, variables)
52
+ # Extract {{variable}} and {{variable.path}} patterns
53
+ # Mustache uses {{name}} syntax
54
+ text.scan(/\{\{([^}#^\/!>]+)\}\}/).flatten.map(&:strip).uniq.reject do |var|
55
+ resolve_variable(var, variables)
56
+ end
57
+ end
58
+
59
+ # Check if a variable path exists in a hash
60
+ #
61
+ # @param path [String] Dot-separated variable path (e.g., "user.name")
62
+ # @param variables [Hash] Variables to search
63
+ # @return [Object, nil] The value if found, nil otherwise
64
+ def resolve_variable(path, variables)
65
+ parts = path.split(".")
66
+ value = variables
67
+
68
+ parts.each do |part|
69
+ return nil unless value.is_a?(Hash)
70
+ # Try both string and symbol keys
71
+ value = value[part] || value[part.to_sym]
72
+ return nil if value.nil?
73
+ end
74
+
75
+ value
76
+ end
77
+
78
+ # Convert hash keys to strings recursively
79
+ #
80
+ # @param hash [Hash] Hash with symbol or string keys
81
+ # @return [Hash] Hash with all string keys
82
+ def stringify_keys(hash)
83
+ return {} unless hash.is_a?(Hash)
84
+
85
+ hash.transform_keys(&:to_s).transform_values do |v|
86
+ v.is_a?(Hash) ? stringify_keys(v) : v
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "internal/template"
5
+
6
+ module Braintrust
7
+ # Prompt class for loading and building prompts from Braintrust
8
+ #
9
+ # @example Load and use a prompt
10
+ # prompt = Braintrust::Prompt.load(project: "my-project", slug: "summarizer")
11
+ # params = prompt.build(text: "Article to summarize...")
12
+ # client.messages.create(**params)
13
+ class Prompt
14
+ attr_reader :id, :name, :slug, :project_id
15
+
16
+ # Load a prompt from Braintrust
17
+ #
18
+ # @param project [String] Project name
19
+ # @param slug [String] Prompt slug
20
+ # @param version [String, nil] Specific version (default: latest)
21
+ # @param defaults [Hash] Default variable values for build()
22
+ # @param api [API, nil] Braintrust API client (default: creates one using global state)
23
+ # @return [Prompt]
24
+ def self.load(project:, slug:, version: nil, defaults: {}, api: nil)
25
+ api ||= API.new
26
+
27
+ # Find the function by project + slug
28
+ result = api.functions.list(project_name: project, slug: slug)
29
+ function = result.dig("objects")&.first
30
+ raise Error, "Prompt '#{slug}' not found in project '#{project}'" unless function
31
+
32
+ # Fetch full function data including prompt_data
33
+ full_data = api.functions.get(id: function["id"], version: version)
34
+
35
+ new(full_data, defaults: defaults)
36
+ end
37
+
38
+ # Initialize a Prompt from function data
39
+ #
40
+ # @param data [Hash] Function data from API
41
+ # @param defaults [Hash] Default variable values for build()
42
+ def initialize(data, defaults: {})
43
+ @data = data
44
+ @defaults = Internal::Template.stringify_keys(defaults)
45
+
46
+ @id = data["id"]
47
+ @name = data["name"]
48
+ @slug = data["slug"]
49
+ @project_id = data["project_id"]
50
+ end
51
+
52
+ # Get the raw prompt definition
53
+ # @return [Hash, nil]
54
+ def prompt
55
+ @data.dig("prompt_data", "prompt")
56
+ end
57
+
58
+ # Get the prompt messages
59
+ # @return [Array<Hash>]
60
+ def messages
61
+ prompt&.dig("messages") || []
62
+ end
63
+
64
+ # Get the tools definition (parsed from JSON string)
65
+ # @return [Array<Hash>, nil]
66
+ def tools
67
+ tools_json = prompt&.dig("tools")
68
+ return nil unless tools_json.is_a?(String) && !tools_json.empty?
69
+
70
+ JSON.parse(tools_json)
71
+ rescue JSON::ParserError
72
+ nil
73
+ end
74
+
75
+ # Get the model name
76
+ # @return [String, nil]
77
+ def model
78
+ @data.dig("prompt_data", "options", "model")
79
+ end
80
+
81
+ # Get model options
82
+ # @return [Hash]
83
+ def options
84
+ @data.dig("prompt_data", "options") || {}
85
+ end
86
+
87
+ # Get the template format
88
+ # @return [String] "mustache" (default), "nunjucks", or "none"
89
+ def template_format
90
+ @data.dig("prompt_data", "template_format") || "mustache"
91
+ end
92
+
93
+ # Build the prompt with variable substitution
94
+ #
95
+ # Returns a hash ready to pass to an LLM client:
96
+ # {model: "...", messages: [...], temperature: ..., ...}
97
+ #
98
+ # @param variables [Hash] Variables to substitute (e.g., {name: "Alice"})
99
+ # @param strict [Boolean] Raise error on missing variables (default: false)
100
+ # @return [Hash] Built prompt ready for LLM client
101
+ #
102
+ # @example With keyword arguments
103
+ # prompt.build(name: "Alice", task: "coding")
104
+ #
105
+ # @example With explicit hash
106
+ # prompt.build({name: "Alice"}, strict: true)
107
+ def build(variables = nil, strict: false, **kwargs)
108
+ # Support both explicit hash and keyword arguments
109
+ variables_hash = variables.is_a?(Hash) ? variables : {}
110
+ vars = @defaults
111
+ .merge(Internal::Template.stringify_keys(variables_hash))
112
+ .merge(Internal::Template.stringify_keys(kwargs))
113
+
114
+ # Render Mustache templates in messages
115
+ built_messages = messages.map do |msg|
116
+ {
117
+ role: msg["role"].to_sym,
118
+ content: Internal::Template.render(msg["content"], vars, format: template_format, strict: strict)
119
+ }
120
+ end
121
+
122
+ # Build result with model and messages
123
+ result = {
124
+ model: model,
125
+ messages: built_messages
126
+ }
127
+
128
+ # Add tools if defined
129
+ parsed_tools = tools
130
+ result[:tools] = parsed_tools if parsed_tools
131
+
132
+ # Add params (temperature, max_tokens, etc.) to top level
133
+ params = options.dig("params")
134
+ if params.is_a?(Hash)
135
+ params.each do |key, value|
136
+ result[key.to_sym] = value
137
+ end
138
+ end
139
+
140
+ result
141
+ end
142
+ end
143
+ end
@@ -73,7 +73,7 @@ module Braintrust
73
73
  @org_id = org_id
74
74
  @default_project = default_project
75
75
  @app_url = app_url || "https://www.braintrust.dev"
76
- @api_url = api_url
76
+ @api_url = api_url || "https://api.braintrust.dev"
77
77
  @proxy_url = proxy_url
78
78
  @config = config
79
79
 
@@ -4,10 +4,18 @@ require "opentelemetry/sdk"
4
4
  require "opentelemetry/exporter/otlp"
5
5
  require_relative "trace/span_processor"
6
6
  require_relative "trace/span_filter"
7
+ require_relative "internal/env"
7
8
  require_relative "logger"
8
9
 
9
10
  module Braintrust
10
11
  module Trace
12
+ # Track whether the at_exit hook has been registered
13
+ @exit_hook_registered = false
14
+
15
+ class << self
16
+ attr_accessor :exit_hook_registered
17
+ end
18
+
11
19
  # Set up OpenTelemetry tracing with Braintrust
12
20
  # @param state [State] Braintrust state
13
21
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider
@@ -32,6 +40,9 @@ module Braintrust
32
40
  OpenTelemetry.tracer_provider = tracer_provider
33
41
  Log.debug("Created OpenTelemetry tracer provider")
34
42
  end
43
+
44
+ # Register at_exit hook for global provider (only once)
45
+ register_exit_hook
35
46
  end
36
47
 
37
48
  # Enable Braintrust tracing (adds span processor)
@@ -39,6 +50,36 @@ module Braintrust
39
50
  enable(tracer_provider, state: state, config: config, exporter: exporter)
40
51
  end
41
52
 
53
+ # Register an at_exit hook to flush spans before program exit.
54
+ # This ensures buffered spans in BatchSpanProcessor are exported.
55
+ # Only registers once, and only for the global tracer provider.
56
+ # Controlled by BRAINTRUST_FLUSH_ON_EXIT env var (default: true).
57
+ def self.register_exit_hook
58
+ return if @exit_hook_registered
59
+ return unless Internal::Env.flush_on_exit
60
+
61
+ @exit_hook_registered = true
62
+ at_exit { flush_spans }
63
+ Log.debug("Registered at_exit hook for span flushing")
64
+ end
65
+
66
+ # Flush buffered spans from the global tracer provider.
67
+ # Forces immediate export of any spans buffered by BatchSpanProcessor.
68
+ # @return [Boolean] true if flush succeeded, false otherwise
69
+ def self.flush_spans
70
+ provider = OpenTelemetry.tracer_provider
71
+ return false unless provider.respond_to?(:force_flush)
72
+
73
+ Log.debug("Flushing spans")
74
+ begin
75
+ provider.force_flush
76
+ true
77
+ rescue => e
78
+ Log.debug("Failed to flush spans: #{e.message}")
79
+ false
80
+ end
81
+ end
82
+
42
83
  def self.enable(tracer_provider, state: nil, exporter: nil, config: nil)
43
84
  state ||= Braintrust.current_state
44
85
  raise Error, "No state available" unless state
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored from mustache gem v1.1.1
4
+ # https://github.com/mustache/mustache
5
+ # License: MIT
6
+ # Modifications: Namespaced under Braintrust::Vendor
7
+
8
+ require_relative "context_miss"
9
+
10
+ module Braintrust
11
+ module Vendor
12
+ class Mustache
13
+ # A Context represents the context which a Mustache template is
14
+ # executed within. All Mustache tags reference keys in the Context.
15
+ class Context
16
+ # Initializes a Mustache::Context.
17
+ #
18
+ # @param [Mustache] mustache A Mustache instance.
19
+ #
20
+ def initialize(mustache)
21
+ @stack = [mustache]
22
+ @partial_template_cache = {}
23
+ end
24
+
25
+ # A {{>partial}} tag translates into a call to the context's
26
+ # `partial` method, which would be this sucker right here.
27
+ #
28
+ # If the Mustache view handling the rendering (e.g. the view
29
+ # representing your profile page or some other template) responds
30
+ # to `partial`, we call it and render the result.
31
+ #
32
+ def partial(name, indentation = "")
33
+ # Look for the first Mustache in the stack.
34
+ mustache = mustache_in_stack
35
+
36
+ # Indent the partial template by the given indentation.
37
+ part = mustache.partial(name).to_s.gsub(/^/, indentation)
38
+
39
+ # Get a template object for the partial and render the result.
40
+ template_for_partial(part).render(self)
41
+ end
42
+
43
+ def template_for_partial(partial)
44
+ @partial_template_cache[partial] ||= Template.new(partial)
45
+ end
46
+
47
+ # Find the first Mustache in the stack.
48
+ #
49
+ # If we're being rendered inside a Mustache object as a context,
50
+ # we'll use that one.
51
+ #
52
+ # @return [Mustache] First Mustache in the stack.
53
+ #
54
+ def mustache_in_stack
55
+ @mustache_in_stack ||= @stack.find { |frame| frame.is_a?(Mustache) }
56
+ end
57
+
58
+ # Allows customization of how Mustache escapes things.
59
+ #
60
+ # @param [Object] value Value to escape.
61
+ #
62
+ # @return [String] Escaped string.
63
+ #
64
+ def escape(value)
65
+ mustache_in_stack.escape(value)
66
+ end
67
+
68
+ # Adds a new object to the context's internal stack.
69
+ #
70
+ # @param [Object] new_obj Object to be added to the internal stack.
71
+ #
72
+ # @return [Context] Returns the Context.
73
+ #
74
+ def push(new_obj)
75
+ @stack.unshift(new_obj)
76
+ @mustache_in_stack = nil
77
+ self
78
+ end
79
+
80
+ # Removes the most recently added object from the context's
81
+ # internal stack.
82
+ #
83
+ # @return [Context] Returns the Context.
84
+ #
85
+ def pop
86
+ @stack.shift
87
+ @mustache_in_stack = nil
88
+ self
89
+ end
90
+
91
+ # Can be used to add a value to the context in a hash-like way.
92
+ #
93
+ # context[:name] = "Chris"
94
+ def []=(name, value)
95
+ push(name => value)
96
+ end
97
+
98
+ # Alias for `fetch`.
99
+ def [](name)
100
+ fetch(name, nil)
101
+ end
102
+
103
+ # Do we know about a particular key? In other words, will calling
104
+ # `context[key]` give us a result that was set. Basically.
105
+ def has_key?(key)
106
+ fetch(key, false)
107
+ rescue ContextMiss
108
+ false
109
+ end
110
+
111
+ # Similar to Hash#fetch, finds a value by `name` in the context's
112
+ # stack. You may specify the default return value by passing a
113
+ # second parameter.
114
+ #
115
+ # If no second parameter is passed (or raise_on_context_miss is
116
+ # set to true), will raise a ContextMiss exception on miss.
117
+ def fetch(name, default = :__raise)
118
+ @stack.each do |frame|
119
+ # Prevent infinite recursion.
120
+ next if frame == self
121
+
122
+ value = find(frame, name, :__missing)
123
+ return value if :__missing != value
124
+ end
125
+
126
+ if default == :__raise || mustache_in_stack.raise_on_context_miss?
127
+ raise ContextMiss.new("Can't find #{name} in #{@stack.inspect}")
128
+ else
129
+ default
130
+ end
131
+ end
132
+
133
+ # Finds a key in an object, using whatever method is most
134
+ # appropriate. If the object is a hash, does a simple hash lookup.
135
+ # If it's an object that responds to the key as a method call,
136
+ # invokes that method. You get the idea.
137
+ #
138
+ # @param [Object] obj The object to perform the lookup on.
139
+ # @param [String,Symbol] key The key whose value you want
140
+ # @param [Object] default An optional default value, to return if the key is not found.
141
+ #
142
+ # @return [Object] The value of key in object if it is found, and default otherwise.
143
+ #
144
+ def find(obj, key, default = nil)
145
+ return find_in_hash(obj.to_hash, key, default) if obj.respond_to?(:to_hash)
146
+
147
+ unless obj.respond_to?(key)
148
+ # no match for the key, but it may include a hyphen, so try again replacing hyphens with underscores.
149
+ key = key.to_s.tr("-", "_")
150
+ return default unless obj.respond_to?(key)
151
+ end
152
+
153
+ meth = obj.method(key) rescue proc { obj.send(key) }
154
+ meth.arity == 1 ? meth.to_proc : meth.call
155
+ end
156
+
157
+ def current
158
+ @stack.first
159
+ end
160
+
161
+ private
162
+
163
+ # Fetches a hash key if it exists, or returns the given default.
164
+ def find_in_hash(obj, key, default)
165
+ return obj[key] if obj.has_key?(key)
166
+ return obj[key.to_s] if obj.has_key?(key.to_s)
167
+ return obj[key] if obj.respond_to?(:default_proc) && obj.default_proc && obj[key]
168
+
169
+ # If default is :__missing then we are from #fetch which is hunting through the stack
170
+ # If default is nil then we are reducing dot notation
171
+ if :__missing != default && mustache_in_stack.raise_on_context_miss?
172
+ raise ContextMiss.new("Can't find #{key} in #{obj}")
173
+ else
174
+ obj.fetch(key, default)
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored from mustache gem v1.1.1
4
+ # https://github.com/mustache/mustache
5
+ # License: MIT
6
+ # Modifications: Namespaced under Braintrust::Vendor
7
+
8
+ module Braintrust
9
+ module Vendor
10
+ class Mustache
11
+ # A ContextMiss is raised whenever a tag's target can not be found
12
+ # in the current context if `Mustache#raise_on_context_miss?` is
13
+ # set to true.
14
+ #
15
+ # For example, if your View class does not respond to `music` but
16
+ # your template contains a `{{music}}` tag this exception will be raised.
17
+ #
18
+ # By default it is not raised. See Mustache.raise_on_context_miss.
19
+ class ContextMiss < RuntimeError; end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored from mustache gem v1.1.1
4
+ # https://github.com/mustache/mustache
5
+ # License: MIT
6
+ # Modifications: Namespaced under Braintrust::Vendor
7
+
8
+ module Braintrust
9
+ module Vendor
10
+ class Mustache
11
+ Enumerable = Module.new
12
+ end
13
+ end
14
+ end