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 +7 -0
- data/README.md +24 -0
- data/lib/braintrust/api/datasets.rb +198 -0
- data/lib/braintrust/api/functions.rb +152 -0
- data/lib/braintrust/api/internal/auth.rb +97 -0
- data/lib/braintrust/api.rb +29 -0
- data/lib/braintrust/config.rb +30 -0
- data/lib/braintrust/eval/case.rb +12 -0
- data/lib/braintrust/eval/cases.rb +58 -0
- data/lib/braintrust/eval/functions.rb +137 -0
- data/lib/braintrust/eval/result.rb +53 -0
- data/lib/braintrust/eval/scorer.rb +108 -0
- data/lib/braintrust/eval.rb +418 -0
- data/lib/braintrust/internal/experiments.rb +129 -0
- data/lib/braintrust/logger.rb +32 -0
- data/lib/braintrust/state.rb +121 -0
- data/lib/braintrust/trace/openai.rb +87 -0
- data/lib/braintrust/trace/span_processor.rb +71 -0
- data/lib/braintrust/trace.rb +108 -0
- data/lib/braintrust/version.rb +5 -0
- data/lib/braintrust.rb +110 -0
- metadata +176 -0
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
|