braintrust 0.0.1.alpha.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2f2fd3e289b473dc2f40c2fe9df914c1b1b7ee2ac06db54656fa24eb06a3ff5a
4
+ data.tar.gz: 562aadfd39d224e5052685ed73c4f2a504db4df01fd26445bf4b0411a8987bb5
5
+ SHA512:
6
+ metadata.gz: db90cc0be88cd3c5a88b59d0b87829c88cec76f15bb1213840efb239474a389c95be62ba41efcdb0b87e069845f71873ee7cfefc224e2ba1d4e5d7affbd536a3
7
+ data.tar.gz: 8ff1ace52cc31b7feae57c557e029f09f8fd811cc2163fb042a5b9e9965b6a9eef2f00e9c543d5c177ec52b04163b119bb5c68dfb57d73d72d7dc63b2974f213
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Braintrust Ruby SDK
2
+
3
+ Ruby SDK for [Braintrust](https://www.braintrust.dev) - AI evaluation and observability platform.
4
+
5
+ ## Status
6
+
7
+ 🚧 Under active development
8
+
9
+ ## Development
10
+
11
+ ```bash
12
+ ./scripts/install-deps.sh
13
+ mise install
14
+ cp .env.example .env
15
+ rake test
16
+ ```
17
+
18
+ ## License
19
+
20
+ Apache-2.0
21
+
22
+ ## Contributing
23
+
24
+ See [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../logger"
7
+
8
+ module Braintrust
9
+ class API
10
+ # Datasets API namespace
11
+ # Provides methods for creating, fetching, and querying datasets
12
+ class Datasets
13
+ def initialize(api)
14
+ @api = api
15
+ @state = api.state
16
+ end
17
+
18
+ # List datasets with optional filters
19
+ # GET /v1/dataset?project_name=X&dataset_name=Y&...
20
+ # @param project_name [String, nil] Filter by project name
21
+ # @param dataset_name [String, nil] Filter by dataset name
22
+ # @param project_id [String, nil] Filter by project ID
23
+ # @param limit [Integer, nil] Limit number of results
24
+ # @return [Hash] Response with "objects" array
25
+ def list(project_name: nil, dataset_name: nil, project_id: nil, limit: nil)
26
+ params = {}
27
+ params["project_name"] = project_name if project_name
28
+ params["dataset_name"] = dataset_name if dataset_name
29
+ params["project_id"] = project_id if project_id
30
+ params["limit"] = limit if limit
31
+
32
+ http_get("/v1/dataset", params)
33
+ end
34
+
35
+ # Fetch exactly one dataset by project + name (convenience method)
36
+ # @param project_name [String] Project name
37
+ # @param name [String] Dataset name
38
+ # @return [Hash] Dataset metadata
39
+ # @raise [Braintrust::Error] if dataset not found
40
+ def get(project_name:, name:)
41
+ result = list(project_name: project_name, dataset_name: name)
42
+ metadata = result["objects"]&.first
43
+ raise Error, "Dataset '#{name}' not found in project '#{project_name}'" unless metadata
44
+ metadata
45
+ end
46
+
47
+ # Fetch dataset metadata by ID
48
+ # GET /v1/dataset/{id}
49
+ # @param id [String] Dataset UUID
50
+ # @return [Hash] Dataset metadata
51
+ def get_by_id(id:)
52
+ http_get("/v1/dataset/#{id}")
53
+ end
54
+
55
+ # Create or register a dataset (idempotent)
56
+ # Uses app API /api/dataset/register which is idempotent - calling this method
57
+ # multiple times with the same name will return the existing dataset.
58
+ # @param project_name [String, nil] Project name
59
+ # @param project_id [String, nil] Project ID
60
+ # @param name [String] Dataset name
61
+ # @param description [String, nil] Optional description
62
+ # @param metadata [Hash, nil] Optional metadata
63
+ # @return [Hash] Response with "project", "dataset", and optional "found_existing" keys.
64
+ # The "found_existing" field is true if the dataset already existed, false/nil if newly created.
65
+ def create(name:, project_name: nil, project_id: nil, description: nil, metadata: nil)
66
+ payload = {dataset_name: name, org_id: @state.org_id}
67
+ payload[:project_name] = project_name if project_name
68
+ payload[:project_id] = project_id if project_id
69
+ payload[:description] = description if description
70
+ payload[:metadata] = metadata if metadata
71
+
72
+ http_post_json_app("/api/dataset/register", payload)
73
+ end
74
+
75
+ # Insert events into a dataset
76
+ # POST /v1/dataset/{id}/insert
77
+ # @param id [String] Dataset UUID
78
+ # @param events [Array<Hash>] Array of event records
79
+ # @return [Hash] Insert response
80
+ def insert(id:, events:)
81
+ http_post_json("/v1/dataset/#{id}/insert", {events: events})
82
+ end
83
+
84
+ # Generate a permalink URL to view a dataset in the Braintrust UI
85
+ # @param id [String] Dataset UUID
86
+ # @return [String] Permalink URL
87
+ def permalink(id:)
88
+ "#{@state.app_url}/app/#{@state.org_name}/object?object_type=dataset&object_id=#{id}"
89
+ end
90
+
91
+ # Fetch records from dataset using BTQL
92
+ # POST /btql
93
+ # @param id [String] Dataset UUID
94
+ # @param limit [Integer] Max records per page (default: 1000)
95
+ # @param cursor [String, nil] Pagination cursor
96
+ # @param version [String, nil] Dataset version
97
+ # @return [Hash] Hash with :records array and :cursor string
98
+ def fetch(id:, limit: 1000, cursor: nil, version: nil)
99
+ query = {
100
+ from: {
101
+ op: "function",
102
+ name: {op: "ident", name: ["dataset"]},
103
+ args: [{op: "literal", value: id}]
104
+ },
105
+ select: [{op: "star"}],
106
+ limit: limit
107
+ }
108
+ query[:cursor] = cursor if cursor
109
+
110
+ payload = {query: query, fmt: "jsonl"}
111
+ payload[:version] = version if version
112
+
113
+ response = http_post_json_raw("/btql", payload)
114
+
115
+ # Parse JSONL response
116
+ records = response.body.lines
117
+ .map { |line| JSON.parse(line.strip) if line.strip.length > 0 }
118
+ .compact
119
+
120
+ # Extract pagination cursor from headers
121
+ next_cursor = response["x-bt-cursor"] || response["x-amz-meta-bt-cursor"]
122
+
123
+ {records: records, cursor: next_cursor}
124
+ end
125
+
126
+ private
127
+
128
+ # Core HTTP request method with logging
129
+ # @param method [Symbol] :get or :post
130
+ # @param path [String] API path
131
+ # @param params [Hash] Query params (for GET)
132
+ # @param payload [Hash, nil] JSON payload (for POST)
133
+ # @param base_url [String, nil] Override base URL (default: api_url)
134
+ # @param parse_json [Boolean] Whether to parse response as JSON (default: true)
135
+ # @return [Hash, Net::HTTPResponse] Parsed JSON or raw response
136
+ def http_request(method, path, params: {}, payload: nil, base_url: nil, parse_json: true)
137
+ # Build URI
138
+ base = base_url || @state.api_url
139
+ uri = URI("#{base}#{path}")
140
+ uri.query = URI.encode_www_form(params) unless params.empty?
141
+
142
+ # Create request
143
+ request = case method
144
+ when :get
145
+ Net::HTTP::Get.new(uri)
146
+ when :post
147
+ req = Net::HTTP::Post.new(uri)
148
+ req["Content-Type"] = "application/json"
149
+ req.body = JSON.dump(payload) if payload
150
+ req
151
+ else
152
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
153
+ end
154
+
155
+ request["Authorization"] = "Bearer #{@state.api_key}"
156
+
157
+ # Execute request with timing
158
+ start_time = Time.now
159
+ Log.debug("[API] #{method.upcase} #{uri}")
160
+
161
+ http = Net::HTTP.new(uri.host, uri.port)
162
+ http.use_ssl = (uri.scheme == "https")
163
+ response = http.request(request)
164
+
165
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
166
+ Log.debug("[API] #{method.upcase} #{uri} -> #{response.code} (#{duration_ms}ms, #{response.body.bytesize} bytes)")
167
+
168
+ # Handle response
169
+ unless response.is_a?(Net::HTTPSuccess)
170
+ Log.debug("[API] Error response body: #{response.body}")
171
+ raise Error, "HTTP #{response.code} for #{method.upcase} #{uri}: #{response.body}"
172
+ end
173
+
174
+ parse_json ? JSON.parse(response.body) : response
175
+ end
176
+
177
+ # HTTP GET with query params - returns parsed JSON
178
+ def http_get(path, params = {})
179
+ http_request(:get, path, params: params)
180
+ end
181
+
182
+ # HTTP POST with JSON body - returns parsed JSON
183
+ def http_post_json(path, payload)
184
+ http_request(:post, path, payload: payload)
185
+ end
186
+
187
+ # HTTP POST to app URL (not API URL) - returns parsed JSON
188
+ def http_post_json_app(path, payload)
189
+ http_request(:post, path, payload: payload, base_url: @state.app_url)
190
+ end
191
+
192
+ # HTTP POST with JSON body - returns raw response (for header access)
193
+ def http_post_json_raw(path, payload)
194
+ http_request(:post, path, payload: payload, parse_json: false)
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../logger"
7
+
8
+ module Braintrust
9
+ class API
10
+ # Functions API namespace
11
+ # Provides methods for creating, invoking, and managing remote functions (prompts)
12
+ class Functions
13
+ def initialize(api)
14
+ @api = api
15
+ @state = api.state
16
+ end
17
+
18
+ # List functions with optional filters
19
+ # GET /v1/function?project_name=X&...
20
+ # @param project_name [String, nil] Filter by project name
21
+ # @param function_name [String, nil] Filter by function name
22
+ # @param slug [String, nil] Filter by slug
23
+ # @param limit [Integer, nil] Limit number of results
24
+ # @return [Hash] Response with "objects" array
25
+ def list(project_name: nil, function_name: nil, slug: nil, limit: nil)
26
+ params = {}
27
+ params["project_name"] = project_name if project_name
28
+ params["function_name"] = function_name if function_name
29
+ params["slug"] = slug if slug
30
+ params["limit"] = limit if limit
31
+
32
+ http_get("/v1/function", params)
33
+ end
34
+
35
+ # Create or register a function (idempotent)
36
+ # POST /v1/function
37
+ # This method is idempotent - if a function with the same slug already exists in the project,
38
+ # it will return the existing function unmodified. Unlike datasets, the response does not
39
+ # include a "found_existing" field.
40
+ # @param project_name [String] Project name
41
+ # @param slug [String] Function slug (URL-friendly identifier)
42
+ # @param function_data [Hash] Function configuration (usually {type: "prompt"})
43
+ # @param prompt_data [Hash, nil] Prompt configuration (prompt, options, etc.)
44
+ # @param name [String, nil] Optional display name (defaults to slug)
45
+ # @param description [String, nil] Optional description
46
+ # @return [Hash] Function metadata
47
+ def create(project_name:, slug:, function_data:, prompt_data: nil, name: nil, description: nil)
48
+ # Look up project ID
49
+ projects_result = http_get("/v1/project", {"project_name" => project_name})
50
+ project = projects_result["objects"]&.first
51
+ raise Error, "Project '#{project_name}' not found" unless project
52
+ project_id = project["id"]
53
+
54
+ payload = {
55
+ project_id: project_id,
56
+ slug: slug,
57
+ name: name || slug, # Name is required, default to slug
58
+ function_data: function_data
59
+ }
60
+ payload[:prompt_data] = prompt_data if prompt_data
61
+ payload[:description] = description if description
62
+
63
+ http_post_json("/v1/function", payload)
64
+ end
65
+
66
+ # Invoke a function by ID with input
67
+ # POST /v1/function/{id}/invoke
68
+ # @param id [String] Function UUID
69
+ # @param input [Object] Input data to pass to the function
70
+ # @return [Object] The function output (String, Hash, Array, etc.) as returned by the HTTP API
71
+ def invoke(id:, input:)
72
+ payload = {input: input}
73
+ http_post_json("/v1/function/#{id}/invoke", payload)
74
+ end
75
+
76
+ # Delete a function by ID
77
+ # DELETE /v1/function/{id}
78
+ # @param id [String] Function UUID
79
+ # @return [Hash] Delete response
80
+ def delete(id:)
81
+ http_delete("/v1/function/#{id}")
82
+ end
83
+
84
+ private
85
+
86
+ # Core HTTP request method with logging
87
+ # @param method [Symbol] :get, :post, or :delete
88
+ # @param path [String] API path
89
+ # @param params [Hash] Query params (for GET)
90
+ # @param payload [Hash, nil] JSON payload (for POST)
91
+ # @param parse_json [Boolean] Whether to parse response as JSON (default: true)
92
+ # @return [Hash, Net::HTTPResponse] Parsed JSON or raw response
93
+ def http_request(method, path, params: {}, payload: nil, parse_json: true)
94
+ # Build URI
95
+ base = @state.api_url
96
+ uri = URI("#{base}#{path}")
97
+ uri.query = URI.encode_www_form(params) unless params.empty?
98
+
99
+ # Create request
100
+ request = case method
101
+ when :get
102
+ Net::HTTP::Get.new(uri)
103
+ when :post
104
+ req = Net::HTTP::Post.new(uri)
105
+ req["Content-Type"] = "application/json"
106
+ req.body = JSON.dump(payload) if payload
107
+ req
108
+ when :delete
109
+ Net::HTTP::Delete.new(uri)
110
+ else
111
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
112
+ end
113
+
114
+ request["Authorization"] = "Bearer #{@state.api_key}"
115
+
116
+ # Execute request with timing
117
+ start_time = Time.now
118
+ Log.debug("[API] #{method.upcase} #{uri}")
119
+
120
+ http = Net::HTTP.new(uri.host, uri.port)
121
+ http.use_ssl = (uri.scheme == "https")
122
+ response = http.request(request)
123
+
124
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
125
+ Log.debug("[API] #{method.upcase} #{uri} -> #{response.code} (#{duration_ms}ms, #{response.body.bytesize} bytes)")
126
+
127
+ # Handle response
128
+ unless response.is_a?(Net::HTTPSuccess)
129
+ Log.debug("[API] Error response body: #{response.body}")
130
+ raise Error, "HTTP #{response.code} for #{method.upcase} #{uri}: #{response.body}"
131
+ end
132
+
133
+ parse_json ? JSON.parse(response.body) : response
134
+ end
135
+
136
+ # HTTP GET with query params - returns parsed JSON
137
+ def http_get(path, params = {})
138
+ http_request(:get, path, params: params)
139
+ end
140
+
141
+ # HTTP POST with JSON body - returns parsed JSON
142
+ def http_post_json(path, payload)
143
+ http_request(:post, path, payload: payload)
144
+ end
145
+
146
+ # HTTP DELETE - returns parsed JSON
147
+ def http_delete(path)
148
+ http_request(:delete, path)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../../logger"
7
+
8
+ module Braintrust
9
+ class API
10
+ module Internal
11
+ module Auth
12
+ # Result of a successful login
13
+ AuthResult = Struct.new(:org_id, :org_name, :api_url, :proxy_url, keyword_init: true)
14
+
15
+ # Mask API key for logging (show first 8 chars)
16
+ def self.mask_api_key(api_key)
17
+ return "nil" if api_key.nil?
18
+ return api_key if api_key.length <= 8
19
+ "#{api_key[0...8]}...#{api_key[-4..]}"
20
+ end
21
+
22
+ # Login to Braintrust API
23
+ # @param api_key [String] Braintrust API key
24
+ # @param app_url [String] Braintrust app URL
25
+ # @param org_name [String, nil] Optional org name to filter by
26
+ # @return [AuthResult] org info
27
+ # @raise [Braintrust::Error] if login fails
28
+ def self.login(api_key:, app_url:, org_name: nil)
29
+ masked_key = mask_api_key(api_key)
30
+ Log.debug("Login: attempting login with API key #{masked_key}, org #{org_name.inspect}, app URL #{app_url}")
31
+
32
+ uri = URI("#{app_url}/api/apikey/login")
33
+ request = Net::HTTP::Post.new(uri)
34
+ request["Authorization"] = "Bearer #{api_key}"
35
+
36
+ http = Net::HTTP.new(uri.hostname, uri.port)
37
+ http.use_ssl = true if uri.scheme == "https"
38
+
39
+ response = http.start do |http_session|
40
+ http_session.request(request)
41
+ end
42
+
43
+ Log.debug("Login: received response [#{response.code}]")
44
+
45
+ # Handle different status codes
46
+ case response
47
+ when Net::HTTPUnauthorized, Net::HTTPForbidden
48
+ raise Error, "Invalid API key: [#{response.code}]"
49
+ when Net::HTTPBadRequest
50
+ raise Error, "Bad request: [#{response.code}] #{response.body}"
51
+ when Net::HTTPClientError
52
+ raise Error, "Client error: [#{response.code}] #{response.message}"
53
+ when Net::HTTPServerError
54
+ raise Error, "Server error: [#{response.code}] #{response.message}"
55
+ when Net::HTTPSuccess
56
+ # Success - continue processing
57
+ else
58
+ raise Error, "Unexpected response: [#{response.code}] #{response.message}"
59
+ end
60
+
61
+ data = JSON.parse(response.body)
62
+ org_info_list = data["org_info"]
63
+
64
+ if org_info_list.nil? || org_info_list.empty?
65
+ raise Error, "No organizations found for API key"
66
+ end
67
+
68
+ # Select org: filter by org_name if present, else take first
69
+ org_info = if org_name
70
+ found = org_info_list.find { |org| org["name"] == org_name }
71
+ if found
72
+ Log.debug("Login: selected org '#{org_name}' (id: #{found["id"]})")
73
+ found
74
+ else
75
+ available = org_info_list.map { |o| o["name"] }.join(", ")
76
+ raise Error, "Organization '#{org_name}' not found. Available: #{available}"
77
+ end
78
+ else
79
+ selected = org_info_list.first
80
+ Log.debug("Login: selected first org '#{selected["name"]}' (id: #{selected["id"]})")
81
+ selected
82
+ end
83
+
84
+ result = AuthResult.new(
85
+ org_id: org_info["id"],
86
+ org_name: org_info["name"],
87
+ api_url: org_info["api_url"],
88
+ proxy_url: org_info["proxy_url"]
89
+ )
90
+
91
+ Log.debug("Login: successfully logged in as org '#{result.org_name}' (#{result.org_id})")
92
+ result
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api/datasets"
4
+ require_relative "api/functions"
5
+
6
+ module Braintrust
7
+ # API client for Braintrust REST API
8
+ # Provides namespaced access to different API resources
9
+ class API
10
+ attr_reader :state
11
+
12
+ def initialize(state: nil)
13
+ @state = state || Braintrust.current_state
14
+ raise Error, "No state available" unless @state
15
+ end
16
+
17
+ # Access to datasets API
18
+ # @return [API::Datasets]
19
+ def datasets
20
+ @datasets ||= API::Datasets.new(self)
21
+ end
22
+
23
+ # Access to functions API
24
+ # @return [API::Functions]
25
+ def functions
26
+ @functions ||= API::Functions.new(self)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ # Configuration object that reads from environment variables
5
+ # and allows overriding with explicit options
6
+ class Config
7
+ attr_reader :api_key, :org_name, :default_parent, :app_url, :api_url
8
+
9
+ def initialize(api_key: nil, org_name: nil, default_parent: nil, app_url: nil, api_url: nil)
10
+ @api_key = api_key
11
+ @org_name = org_name
12
+ @default_parent = default_parent
13
+ @app_url = app_url
14
+ @api_url = api_url
15
+ end
16
+
17
+ # Create a Config from environment variables, with option overrides
18
+ # Passed-in options take priority over ENV vars
19
+ def self.from_env(**options)
20
+ defaults = {
21
+ api_key: ENV["BRAINTRUST_API_KEY"],
22
+ org_name: ENV["BRAINTRUST_ORG_NAME"],
23
+ default_parent: ENV["BRAINTRUST_DEFAULT_PROJECT"],
24
+ app_url: ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
25
+ api_url: ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev"
26
+ }
27
+ new(**defaults.merge(options))
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Eval
5
+ # Case represents a single test case in an evaluation
6
+ # @attr input [Object] The input to the task
7
+ # @attr expected [Object, nil] The expected output (optional)
8
+ # @attr tags [Array<String>, nil] Optional tags for filtering/grouping
9
+ # @attr metadata [Hash, nil] Optional metadata for the case
10
+ Case = Struct.new(:input, :expected, :tags, :metadata, keyword_init: true)
11
+ end
12
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "case"
4
+
5
+ module Braintrust
6
+ module Eval
7
+ # Cases wraps test case data (arrays or enumerables) and normalizes them to Case objects
8
+ # Supports lazy evaluation for memory-efficient processing of large datasets
9
+ class Cases
10
+ include Enumerable
11
+
12
+ # Create a new Cases wrapper
13
+ # @param enumerable [Array, Enumerable] The test cases (hashes or Case objects)
14
+ def initialize(enumerable)
15
+ unless enumerable.respond_to?(:each)
16
+ raise ArgumentError, "Cases must be enumerable (respond to :each)"
17
+ end
18
+
19
+ @enumerable = enumerable
20
+ end
21
+
22
+ # Iterate over cases, normalizing each to a Case object
23
+ # @yield [Case] Each test case
24
+ def each
25
+ return enum_for(:each) unless block_given?
26
+
27
+ @enumerable.each do |item|
28
+ yield normalize_case(item)
29
+ end
30
+ end
31
+
32
+ # Get the count of cases
33
+ # Note: For lazy enumerators, this will force evaluation
34
+ # @return [Integer]
35
+ def count
36
+ @enumerable.count
37
+ end
38
+
39
+ private
40
+
41
+ # Normalize a case item to a Case object
42
+ # @param item [Hash, Case] The case item
43
+ # @return [Case]
44
+ def normalize_case(item)
45
+ case item
46
+ when Case
47
+ # Already a Case object
48
+ item
49
+ when Hash
50
+ # Convert hash to Case object
51
+ Case.new(**item)
52
+ else
53
+ raise ArgumentError, "Case must be a Hash or Case object, got #{item.class}"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end