braintrust 0.1.3 → 0.2.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.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Braintrust
6
+ module Server
7
+ module Handlers
8
+ # GET/POST /list — returns all evaluators keyed by name.
9
+ #
10
+ # Response format (Braintrust dev server protocol):
11
+ # {
12
+ # "evaluator-name": {
13
+ # "parameters": { # optional
14
+ # "type": "braintrust.staticParameters",
15
+ # "schema": {
16
+ # "param_name": { "type": "data", "schema": {...}, "default": ..., "description": ... }
17
+ # },
18
+ # "source": null
19
+ # },
20
+ # "scores": [{ "name": "scorer_name" }, ...]
21
+ # }
22
+ # }
23
+ class List
24
+ def initialize(evaluators)
25
+ @evaluators = evaluators
26
+ end
27
+
28
+ def call(_env)
29
+ result = {}
30
+ @evaluators.each do |name, evaluator|
31
+ scores = (evaluator.scorers || []).each_with_index.map do |scorer, i|
32
+ scorer_name = scorer.respond_to?(:name) ? scorer.name : "score_#{i}"
33
+ {"name" => scorer_name}
34
+ end
35
+ entry = {"scores" => scores}
36
+ params = serialize_parameters(evaluator.parameters)
37
+ entry["parameters"] = params if params
38
+ result[name] = entry
39
+ end
40
+
41
+ [200, {"content-type" => "application/json"},
42
+ [JSON.dump(result)]]
43
+ end
44
+
45
+ private
46
+
47
+ # Convert user-defined parameters to the dev server protocol format.
48
+ # Wraps in a staticParameters container with "data" typed entries.
49
+ def serialize_parameters(parameters)
50
+ return nil unless parameters && !parameters.empty?
51
+
52
+ schema = {}
53
+ parameters.each do |name, spec|
54
+ spec = spec.transform_keys(&:to_s) if spec.is_a?(Hash)
55
+ if spec.is_a?(Hash)
56
+ schema[name.to_s] = {
57
+ "type" => "data",
58
+ "schema" => {"type" => spec["type"] || "string"},
59
+ "default" => spec["default"],
60
+ "description" => spec["description"]
61
+ }
62
+ end
63
+ end
64
+
65
+ {
66
+ "type" => "braintrust.staticParameters",
67
+ "schema" => schema,
68
+ "source" => nil
69
+ }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Braintrust
6
+ module Server
7
+ module Middleware
8
+ # Auth middleware that validates requests using a pluggable strategy.
9
+ # Sets env["braintrust.auth"] with the authentication result on success.
10
+ class Auth
11
+ def initialize(app, strategy:)
12
+ @app = app
13
+ @strategy = strategy
14
+ end
15
+
16
+ def call(env)
17
+ auth_result = @strategy.authenticate(env)
18
+ unless auth_result
19
+ return [401, {"content-type" => "application/json"},
20
+ [JSON.dump({"error" => "Unauthorized"})]]
21
+ end
22
+
23
+ env["braintrust.auth"] = auth_result
24
+ @app.call(env)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Server
5
+ module Middleware
6
+ # CORS middleware allowing requests from *.braintrust.dev origins.
7
+ # Handles preflight OPTIONS requests and adds CORS headers to all responses.
8
+ class Cors
9
+ ALLOWED_ORIGIN_PATTERN = /\Ahttps?:\/\/([\w-]+\.)*braintrust\.dev\z/
10
+
11
+ HEADER_ALLOW_ORIGIN = "access-control-allow-origin"
12
+ HEADER_ALLOW_CREDENTIALS = "access-control-allow-credentials"
13
+ HEADER_ALLOW_METHODS = "access-control-allow-methods"
14
+ HEADER_ALLOW_HEADERS = "access-control-allow-headers"
15
+ HEADER_MAX_AGE = "access-control-max-age"
16
+ HEADER_ALLOW_PRIVATE_NETWORK = "access-control-allow-private-network"
17
+ HEADER_EXPOSE_HEADERS = "access-control-expose-headers"
18
+ EXPOSED_HEADERS = "x-bt-cursor, x-bt-found-existing-experiment, x-bt-span-id, x-bt-span-export"
19
+
20
+ ALLOWED_HEADERS = %w[
21
+ content-type
22
+ authorization
23
+ x-amz-date
24
+ x-api-key
25
+ x-amz-security-token
26
+ x-bt-auth-token
27
+ x-bt-parent
28
+ x-bt-org-name
29
+ x-bt-project-id
30
+ x-bt-stream-fmt
31
+ x-bt-use-cache
32
+ x-bt-use-gateway
33
+ x-stainless-os
34
+ x-stainless-lang
35
+ x-stainless-package-version
36
+ x-stainless-runtime
37
+ x-stainless-runtime-version
38
+ x-stainless-arch
39
+ ].freeze
40
+
41
+ def initialize(app)
42
+ @app = app
43
+ end
44
+
45
+ def call(env)
46
+ origin = env["HTTP_ORIGIN"]
47
+
48
+ if env["REQUEST_METHOD"] == "OPTIONS"
49
+ return handle_preflight(env, origin)
50
+ end
51
+
52
+ status, headers, body = @app.call(env)
53
+ add_cors_headers(headers, origin)
54
+ [status, headers, body]
55
+ end
56
+
57
+ private
58
+
59
+ def handle_preflight(env, origin)
60
+ headers = {}
61
+ add_cors_headers(headers, origin)
62
+ headers[HEADER_ALLOW_METHODS] = "GET, POST, OPTIONS"
63
+ headers[HEADER_ALLOW_HEADERS] = ALLOWED_HEADERS.join(", ")
64
+ headers[HEADER_MAX_AGE] = "86400"
65
+
66
+ if env["HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK"] == "true"
67
+ headers[HEADER_ALLOW_PRIVATE_NETWORK] = "true"
68
+ end
69
+
70
+ [204, headers, []]
71
+ end
72
+
73
+ def add_cors_headers(headers, origin)
74
+ return unless origin && allowed_origin?(origin)
75
+
76
+ headers[HEADER_ALLOW_ORIGIN] = origin
77
+ headers[HEADER_ALLOW_CREDENTIALS] = "true"
78
+ headers[HEADER_EXPOSE_HEADERS] = EXPOSED_HEADERS
79
+ end
80
+
81
+ def allowed_origin?(origin)
82
+ ALLOWED_ORIGIN_PATTERN.match?(origin)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Server
5
+ module Rack
6
+ # Builds the Rack middleware stack for the eval server.
7
+ class App
8
+ def self.build(evaluators: {}, auth: :clerk_token)
9
+ router = Router.new
10
+ router.add("GET", "/", Handlers::Health.new)
11
+ list_handler = Handlers::List.new(evaluators)
12
+ router.add("GET", "/list", list_handler)
13
+ router.add("POST", "/list", list_handler)
14
+ router.add("POST", "/eval", Handlers::Eval.new(evaluators))
15
+
16
+ auth_strategy = resolve_auth(auth)
17
+
18
+ app = router
19
+ app = Middleware::Auth.new(app, strategy: auth_strategy)
20
+ Middleware::Cors.new(app)
21
+ end
22
+
23
+ def self.resolve_auth(auth)
24
+ case auth
25
+ when :none
26
+ Auth::NoAuth.new
27
+ when :clerk_token
28
+ Auth::ClerkToken.new
29
+ else
30
+ auth
31
+ end
32
+ end
33
+
34
+ private_class_method :resolve_auth
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rack"
5
+ rescue LoadError
6
+ raise LoadError,
7
+ "The 'rack' gem is required for the Braintrust server. " \
8
+ "Add `gem 'rack'` to your Gemfile."
9
+ end
10
+
11
+ require "json"
12
+ require_relative "../eval"
13
+ require_relative "sse"
14
+ require_relative "auth/no_auth"
15
+ require_relative "auth/clerk_token"
16
+ require_relative "middleware/cors"
17
+ require_relative "middleware/auth"
18
+ require_relative "handlers/health"
19
+ require_relative "handlers/list"
20
+ require_relative "handlers/eval"
21
+ require_relative "router"
22
+ require_relative "rack/app"
23
+
24
+ module Braintrust
25
+ module Server
26
+ module Rack
27
+ # Build the Rack application for the eval server.
28
+ # @param evaluators [Hash<String, Evaluator>] Named evaluators ({ "name" => instance })
29
+ # @param auth [:clerk_token, :none, Object] Auth strategy (default: :clerk_token)
30
+ # @return [#call] Rack application
31
+ def self.app(evaluators: {}, auth: :clerk_token)
32
+ App.build(evaluators: evaluators, auth: auth)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Braintrust
6
+ module Server
7
+ # Simple request router that dispatches to handlers based on method + path.
8
+ # Returns 405 for known paths with wrong method, 404 for unknown paths.
9
+ class Router
10
+ def initialize
11
+ @routes = {}
12
+ end
13
+
14
+ def add(method, path, handler)
15
+ @routes["#{method} #{path}"] = handler
16
+ self
17
+ end
18
+
19
+ def call(env)
20
+ method = env["REQUEST_METHOD"]
21
+ path = env["PATH_INFO"]
22
+
23
+ handler = @routes["#{method} #{path}"]
24
+ return handler.call(env) if handler
25
+
26
+ # Path exists but wrong method
27
+ if @routes.any? { |key, _| key.end_with?(" #{path}") }
28
+ return [405, {"content-type" => "application/json"},
29
+ [JSON.dump({"error" => "Method not allowed"})]]
30
+ end
31
+
32
+ [404, {"content-type" => "application/json"},
33
+ [JSON.dump({"error" => "Not found"})]]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Server
5
+ # Rack-compatible response body that streams SSE events via `each`.
6
+ #
7
+ # Works with Puma (immediate writes), Passenger, and rack-test.
8
+ # WEBrick buffers the entire body and is unsuitable for SSE.
9
+ #
10
+ # Falcon buffers `each`-based bodies as Enumerable; use SSEStreamBody instead.
11
+ class SSEBody
12
+ def initialize(&block)
13
+ @block = block
14
+ end
15
+
16
+ def each
17
+ writer = SSEWriter.new { |chunk| yield chunk }
18
+ @block.call(writer)
19
+ end
20
+ end
21
+
22
+ # Rack 3 streaming response body that writes SSE events via `call(stream)`.
23
+ #
24
+ # Required for servers using the protocol-rack adapter (e.g. Falcon), which
25
+ # dispatches `each`-based bodies through a buffered Enumerable path. Bodies
26
+ # that respond only to `call` are dispatched through the Streaming path for
27
+ # true async writes.
28
+ class SSEStreamBody
29
+ def initialize(&block)
30
+ @block = block
31
+ end
32
+
33
+ def call(stream)
34
+ writer = SSEWriter.new { |chunk| stream.write(chunk) }
35
+ @block.call(writer)
36
+ ensure
37
+ stream.close
38
+ end
39
+ end
40
+
41
+ # Writes formatted SSE events.
42
+ class SSEWriter
43
+ def initialize(&block)
44
+ @write = block
45
+ end
46
+
47
+ def event(type, data = "")
48
+ @write.call("event: #{type}\ndata: #{data}\n\n")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server/rack"
4
+
5
+ module Braintrust
6
+ module Server
7
+ end
8
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "net/http"
4
4
  require_relative "../internal/encoding"
5
+ require_relative "../internal/http"
5
6
  require "uri"
6
7
 
7
8
  module Braintrust
@@ -91,7 +92,8 @@ module Braintrust
91
92
  # att = Braintrust::Trace::Attachment.from_url("https://example.com/image.png")
92
93
  def self.from_url(url)
93
94
  uri = URI.parse(url)
94
- response = Net::HTTP.get_response(uri)
95
+ request = Net::HTTP::Get.new(uri)
96
+ response = Braintrust::Internal::Http.with_redirects(uri, request)
95
97
 
96
98
  unless response.is_a?(Net::HTTPSuccess)
97
99
  raise StandardError, "Failed to fetch URL: #{response.code} #{response.message}"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/exporter/otlp"
4
+
5
+ module Braintrust
6
+ module Trace
7
+ # Custom OTLP exporter that groups spans by braintrust.parent attribute
8
+ # and sets the x-bt-parent HTTP header per group. This is required for
9
+ # the Braintrust OTLP backend to route spans to the correct experiment/project.
10
+ #
11
+ # Thread safety: BatchSpanProcessor serializes export() calls via its
12
+ # @export_mutex, so @headers mutation here is safe.
13
+ class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter
14
+ PARENT_ATTR_KEY = SpanProcessor::PARENT_ATTR_KEY
15
+ PARENT_HEADER = "x-bt-parent"
16
+
17
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
18
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
19
+
20
+ def initialize(endpoint:, api_key:)
21
+ super(endpoint: endpoint, headers: {"Authorization" => "Bearer #{api_key}"})
22
+ end
23
+
24
+ def export(span_data, timeout: nil)
25
+ failed = false
26
+ span_data.group_by { |sd| sd.attributes&.[](PARENT_ATTR_KEY) }.each do |parent_value, spans|
27
+ @headers[PARENT_HEADER] = parent_value if parent_value
28
+ failed = true unless super(spans, timeout: timeout) == SUCCESS
29
+ ensure
30
+ @headers.delete(PARENT_HEADER)
31
+ end
32
+ failed ? FAILURE : SUCCESS
33
+ end
34
+ end
35
+ end
36
+ end
@@ -3,6 +3,7 @@
3
3
  require "opentelemetry/sdk"
4
4
  require "opentelemetry/exporter/otlp"
5
5
  require_relative "trace/span_processor"
6
+ require_relative "trace/span_exporter"
6
7
  require_relative "trace/span_filter"
7
8
  require_relative "internal/env"
8
9
  require_relative "logger"
@@ -88,11 +89,9 @@ module Braintrust
88
89
  config ||= state.respond_to?(:config) ? state.config : nil
89
90
 
90
91
  # Create OTLP HTTP exporter unless override provided
91
- exporter ||= OpenTelemetry::Exporter::OTLP::Exporter.new(
92
+ exporter ||= SpanExporter.new(
92
93
  endpoint: "#{state.api_url}/otel/v1/traces",
93
- headers: {
94
- "Authorization" => "Bearer #{state.api_key}"
95
- }
94
+ api_key: state.api_key
96
95
  )
97
96
 
98
97
  # Use SimpleSpanProcessor for InMemorySpanExporter (testing), BatchSpanProcessor for production
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -234,6 +234,7 @@ files:
234
234
  - lib/braintrust/eval.rb
235
235
  - lib/braintrust/eval/case.rb
236
236
  - lib/braintrust/eval/cases.rb
237
+ - lib/braintrust/eval/evaluator.rb
237
238
  - lib/braintrust/eval/formatter.rb
238
239
  - lib/braintrust/eval/functions.rb
239
240
  - lib/braintrust/eval/result.rb
@@ -242,16 +243,30 @@ files:
242
243
  - lib/braintrust/eval/summary.rb
243
244
  - lib/braintrust/internal/encoding.rb
244
245
  - lib/braintrust/internal/env.rb
246
+ - lib/braintrust/internal/http.rb
245
247
  - lib/braintrust/internal/origin.rb
246
248
  - lib/braintrust/internal/template.rb
247
249
  - lib/braintrust/internal/thread_pool.rb
248
250
  - lib/braintrust/internal/time.rb
249
251
  - lib/braintrust/logger.rb
250
252
  - lib/braintrust/prompt.rb
253
+ - lib/braintrust/server.rb
254
+ - lib/braintrust/server/auth/clerk_token.rb
255
+ - lib/braintrust/server/auth/no_auth.rb
256
+ - lib/braintrust/server/handlers/eval.rb
257
+ - lib/braintrust/server/handlers/health.rb
258
+ - lib/braintrust/server/handlers/list.rb
259
+ - lib/braintrust/server/middleware/auth.rb
260
+ - lib/braintrust/server/middleware/cors.rb
261
+ - lib/braintrust/server/rack.rb
262
+ - lib/braintrust/server/rack/app.rb
263
+ - lib/braintrust/server/router.rb
264
+ - lib/braintrust/server/sse.rb
251
265
  - lib/braintrust/setup.rb
252
266
  - lib/braintrust/state.rb
253
267
  - lib/braintrust/trace.rb
254
268
  - lib/braintrust/trace/attachment.rb
269
+ - lib/braintrust/trace/span_exporter.rb
255
270
  - lib/braintrust/trace/span_filter.rb
256
271
  - lib/braintrust/trace/span_processor.rb
257
272
  - lib/braintrust/vendor/mustache.rb