Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/braintrust/api/datasets.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def http_request(method, path, params: {}, payload: nil, base_url: nil, parse_js
raise ArgumentError, "Unsupported HTTP method: #{method}"
end

request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"

# Execute request with timing
start_time = Time.now
Expand Down
2 changes: 1 addition & 1 deletion lib/braintrust/api/functions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def http_request(method, path, params: {}, payload: nil, parse_json: true)
raise ArgumentError, "Unsupported HTTP method: #{method}"
end

request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"

# Execute request with timing
start_time = Time.now
Expand Down
2 changes: 1 addition & 1 deletion lib/braintrust/api/internal/btql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def execute_query(payload)

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"
request["Accept"] = "application/x-jsonlines"
request.body = JSON.dump(payload)

Expand Down
4 changes: 2 additions & 2 deletions lib/braintrust/api/internal/experiments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def create(name:, project_id:, ensure_new: true, tags: nil, metadata: nil,

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"
request.body = JSON.dump(payload)

response = Braintrust::Internal::Http.with_redirects(uri, request)
Expand All @@ -59,7 +59,7 @@ def delete(id:)
uri = URI("#{@state.api_url}/v1/experiment/#{id}")

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"

response = Braintrust::Internal::Http.with_redirects(uri, request)

Expand Down
4 changes: 2 additions & 2 deletions lib/braintrust/api/internal/projects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def create(name:)

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"
request.body = JSON.dump({name: name})

response = Braintrust::Internal::Http.with_redirects(uri, request)
Expand All @@ -44,7 +44,7 @@ def delete(id:)
uri = URI("#{@state.api_url}/v1/project/#{id}")

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{@state.api_key}"
request["Authorization"] = "Bearer #{@state.api_key!}"

response = Braintrust::Internal::Http.with_redirects(uri, request)

Expand Down
4 changes: 3 additions & 1 deletion lib/braintrust/config.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative "internal/api_key_resolver"

module Braintrust
# Configuration object that reads from environment variables
# and allows overriding with explicit options
Expand Down Expand Up @@ -39,7 +41,7 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni
end

new(
api_key: api_key || ((ENV["BRAINTRUST_API_KEY"] && ENV["BRAINTRUST_API_KEY"].empty?) ? nil : ENV["BRAINTRUST_API_KEY"]),
api_key: Internal::ApiKeyResolver.resolve(explicit_api_key: api_key),
org_name: org_name || ENV["BRAINTRUST_ORG_NAME"],
default_project: default_project || ENV["BRAINTRUST_DEFAULT_PROJECT"],
app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
Expand Down
62 changes: 62 additions & 0 deletions lib/braintrust/internal/api_key_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require "json"

module Braintrust
module Internal
# Resolves the Braintrust API key from explicit options, ENV, or the nearest
# .braintrust.json file without mutating the process environment.
class ApiKeyResolver
ENV_KEY = "BRAINTRUST_API_KEY"
CONFIG_FILE = ".braintrust.json"
SEARCH_PARENT_LIMIT = 64

def self.resolve(explicit_api_key: nil, start_dir: Dir.pwd)
return explicit_api_key unless explicit_api_key.nil?

env_api_key = ENV[ENV_KEY]
return env_api_key if env_api_key && !env_api_key.strip.empty?

find_file_api_key(start_dir)
end

def self.find_file_api_key(start_dir = Dir.pwd)
dir = start_dir

0.upto(SEARCH_PARENT_LIMIT) do
config_path = File.join(dir, CONFIG_FILE)

begin
contents = File.read(config_path)
rescue Errno::ENOENT, Errno::ENOTDIR
# Missing candidates are not boundaries; keep walking upward.
rescue
return nil
else
return parse_api_key(contents)
end

parent = File.dirname(dir)
break if parent == dir
dir = parent
end

nil
rescue
nil
end

def self.parse_api_key(contents)
config = JSON.parse(contents)
return nil unless config.is_a?(Hash)

value = config[ENV_KEY]
(value.is_a?(String) && !value.strip.empty?) ? value : nil
rescue JSON::ParserError, TypeError
nil
end

private_class_method :find_file_api_key, :parse_api_key
end
end
end
2 changes: 1 addition & 1 deletion lib/braintrust/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# require "braintrust/setup"
#
# Environment variables:
# BRAINTRUST_API_KEY - Required for tracing to work
# BRAINTRUST_API_KEY - Required for tracing to work; falls back to .braintrust.json
# BRAINTRUST_AUTO_INSTRUMENT - Set to "false" to disable (default: true)
# BRAINTRUST_INSTRUMENT_ONLY - Comma-separated whitelist
# BRAINTRUST_INSTRUMENT_EXCEPT - Comma-separated blacklist
Expand Down
17 changes: 14 additions & 3 deletions lib/braintrust/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Braintrust
# State object that holds Braintrust configuration
# Thread-safe global state management
class State
class MissingAPIKeyError < ArgumentError; end

attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config

@mutex = Mutex.new
Expand Down Expand Up @@ -66,7 +68,7 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni
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)
# Instance-level mutex for thread-safe login
@login_mutex = Mutex.new
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
raise MissingAPIKeyError, "api_key is required" if api_key.nil? || api_key.empty?

@api_key = api_key
@org_name = org_name
Expand Down Expand Up @@ -101,6 +103,11 @@ def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, a
end
end

def api_key!
raise MissingAPIKeyError, "api_key is required" if @api_key.nil? || @api_key.empty?
@api_key
end

# Thread-safe global state getter
def self.global
@mutex.synchronize { @global_state }
Expand All @@ -121,9 +128,10 @@ def login
@login_mutex.synchronize do
# Return early if already logged in
return self if @logged_in
api_key = api_key!

result = API::Internal::Auth.login(
api_key: @api_key,
api_key: api_key,
app_url: @app_url,
org_name: @org_name
)
Expand Down Expand Up @@ -167,6 +175,9 @@ def login_in_thread
login
Log.debug("Background login succeeded")
break
rescue MissingAPIKeyError => e
Log.debug("Background login skipped: #{e.message}")
break
rescue => e
retry_count += 1
delay = [0.001 * 2**(retry_count - 1), max_delay].min
Expand All @@ -190,7 +201,7 @@ def wait_for_login(timeout = nil)
# Raises ArgumentError if state is invalid
# @return [self]
def validate
raise ArgumentError, "api_key is required" if @api_key.nil? || @api_key.empty?
api_key!
raise ArgumentError, "api_url is required" if @api_url.nil? || @api_url.empty?
raise ArgumentError, "app_url is required" if @app_url.nil? || @app_url.empty?

Expand Down
3 changes: 3 additions & 0 deletions lib/braintrust/trace/span_exporter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "opentelemetry/exporter/otlp"
require_relative "../state"

module Braintrust
module Trace
Expand All @@ -18,6 +19,8 @@ class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter
FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE

def initialize(endpoint:, api_key:)
raise State::MissingAPIKeyError, "api_key is required" if api_key.nil? || api_key.empty?

super(endpoint: endpoint, headers: {"Authorization" => "Bearer #{api_key}"})
end

Expand Down
Loading