diff --git a/src/Braintrust.Sdk/Api/BraintrustApiClient.cs b/src/Braintrust.Sdk/Api/BraintrustApiClient.cs index 8931e07..831e516 100644 --- a/src/Braintrust.Sdk/Api/BraintrustApiClient.cs +++ b/src/Braintrust.Sdk/Api/BraintrustApiClient.cs @@ -154,15 +154,17 @@ public async Task GetOrCreateProjectAndOrgInfo() private async Task Login() { - var request = new LoginRequest(_config.ApiKey); - return await PostAsync("/api/apikey/login", request) + var apiKey = await _config.GetRequiredApiKeyAsync().ConfigureAwait(false); + var request = new LoginRequest(apiKey); + return await PostAsync("/api/apikey/login", request, apiKey: apiKey) .ConfigureAwait(false); } private async Task GetAsync(string path, CancellationToken cancellationToken = default) { + var apiKey = await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -172,10 +174,12 @@ private async Task GetAsync(string path, CancellationToken private async Task PostAsync( string path, TRequest body, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? apiKey = null) { + apiKey ??= await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Post, path); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Content = JsonContent.Create(body, options: _jsonOptions); diff --git a/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs b/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs index c414d97..9920c6d 100644 --- a/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs +++ b/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs @@ -96,8 +96,9 @@ public async Task>> Query private async Task PostBtqlAsync(string query, CancellationToken cancellationToken) { + var apiKey = await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Post, "/btql"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Content = JsonContent.Create(new BtqlRequest(query), options: _jsonOptions); diff --git a/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs b/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs new file mode 100644 index 0000000..8d5eab8 --- /dev/null +++ b/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs @@ -0,0 +1,167 @@ +namespace Braintrust.Sdk.Config; + +internal static class BraintrustApiKeyDiscovery +{ + internal const int BraintrustEnvSearchParentLimit = 64; + private const string BraintrustEnvFileName = ".env.braintrust"; + private const string BraintrustApiKeyName = "BRAINTRUST_API_KEY"; + + internal static async Task FindInBraintrustEnvFileAsync( + string? searchRoot, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(searchRoot)) + { + return null; + } + + var paths = new List(); + for (var dir = searchRoot; paths.Count <= BraintrustEnvSearchParentLimit; dir = Path.GetDirectoryName(dir)!) + { + paths.Add(Path.Combine(dir, BraintrustEnvFileName)); + + var parent = Path.GetDirectoryName(dir); + if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.Ordinal)) + { + break; + } + } + + // Start the reads together, then await nearest-first so a parent file never beats a closer file. + var reads = paths + .Select(path => File.ReadAllTextAsync(path, cancellationToken) + .ContinueWith( + task => new + { + Contents = task.IsCompletedSuccessfully ? task.Result : null, + Error = task.IsCompletedSuccessfully + ? null + : task.IsCanceled + ? new OperationCanceledException(cancellationToken) + : task.Exception?.GetBaseException() + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default)) + .ToArray(); + + for (var i = 0; i < reads.Length; i++) + { + var result = await reads[i].ConfigureAwait(false); + if (result.Error == null) + { + var apiKey = ParseBraintrustApiKey(result.Contents!); + return string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; + } + + if (result.Error is OperationCanceledException cancellation) + { + throw cancellation; + } + + if (result.Error is FileNotFoundException or DirectoryNotFoundException) + { + continue; + } + + return null; + } + + return null; + } + + internal static string? ParseBraintrustApiKey(string contents) + { + string? parsedApiKey = null; + + using var reader = new StringReader(contents); + string? line; + while ((line = reader.ReadLine()) != null) + { + var current = line.TrimStart(); + if (current.Length == 0 || current[0] == '#') + { + continue; + } + + if (current.StartsWith("export", StringComparison.Ordinal)) + { + var afterExport = current["export".Length..]; + if (afterExport.Length > 0 && char.IsWhiteSpace(afterExport[0])) + { + current = afterExport.TrimStart(); + } + } + + var equalsIndex = current.IndexOf('='); + if (equalsIndex <= 0) + { + continue; + } + + var key = current[..equalsIndex].Trim(); + if (!string.Equals(key, BraintrustApiKeyName, StringComparison.Ordinal)) + { + continue; + } + + var rawValue = current[(equalsIndex + 1)..].TrimStart(); + if (rawValue.Length == 0) + { + parsedApiKey = string.Empty; + continue; + } + + if (rawValue[0] is '"' or '\'') + { + var quote = rawValue[0]; + var value = new System.Text.StringBuilder(); + var escaped = false; + var closed = false; + + for (var i = 1; i < rawValue.Length; i++) + { + var ch = rawValue[i]; + if (escaped) + { + value.Append(quote == '"' && ch == 'n' ? '\n' + : quote == '"' && ch == 'r' ? '\r' + : quote == '"' && ch == 't' ? '\t' + : ch); + escaped = false; + } + else if (ch == '\\') + { + escaped = true; + } + else if (ch == quote) + { + closed = true; + break; + } + else + { + value.Append(ch); + } + } + + parsedApiKey = closed ? value.ToString() : rawValue[1..]; + continue; + } + + var commentIndex = -1; + for (var i = 0; i < rawValue.Length; i++) + { + if (rawValue[i] == '#' && (i == 0 || char.IsWhiteSpace(rawValue[i - 1]))) + { + commentIndex = i; + break; + } + } + + parsedApiKey = (commentIndex >= 0 ? rawValue[..commentIndex] : rawValue).Trim(); + } + + return parsedApiKey; + } +} diff --git a/src/Braintrust.Sdk/Config/BraintrustConfig.cs b/src/Braintrust.Sdk/Config/BraintrustConfig.cs index 3afe59a..db83008 100644 --- a/src/Braintrust.Sdk/Config/BraintrustConfig.cs +++ b/src/Braintrust.Sdk/Config/BraintrustConfig.cs @@ -9,7 +9,13 @@ namespace Braintrust.Sdk.Config; /// public sealed class BraintrustConfig : BaseConfig { - public string ApiKey { get; } + private const string ApiKeySettingName = "BRAINTRUST_API_KEY"; + + private readonly bool _hasApiKeyOverride; + private readonly string? _apiKeyOverride; + private readonly string? _braintrustEnvSearchRoot; + + public string ApiKey => GetRequiredApiKeyAsync().GetAwaiter().GetResult(); public string ApiUrl { get; } public string AppUrl { get; } public string TracesPath { get; } @@ -39,7 +45,21 @@ public static BraintrustConfig Of(params (string Key, string? Value)[] envOverri private BraintrustConfig(IDictionary envOverrides) : base(envOverrides) { - ApiKey = GetRequiredConfig("BRAINTRUST_API_KEY"); + try + { + _braintrustEnvSearchRoot = Directory.GetCurrentDirectory(); + } + catch + { + _braintrustEnvSearchRoot = null; + } + + if (envOverrides.TryGetValue(ApiKeySettingName, out var apiKeyOverride)) + { + _hasApiKeyOverride = true; + _apiKeyOverride = apiKeyOverride == NullOverride ? null : apiKeyOverride; + } + ApiUrl = GetConfig("BRAINTRUST_API_URL", "https://api.braintrust.dev"); AppUrl = GetConfig("BRAINTRUST_APP_URL", "https://www.braintrust.dev"); TracesPath = GetConfig("BRAINTRUST_TRACES_PATH", "/otel/v1/traces"); @@ -57,6 +77,53 @@ private BraintrustConfig(IDictionary envOverrides) : base(envOv } } + internal string? TryGetImmediateApiKey() + { + if (_hasApiKeyOverride) + { + return string.IsNullOrWhiteSpace(_apiKeyOverride) ? null : _apiKeyOverride; + } + + var value = Environment.GetEnvironmentVariable(ApiKeySettingName); + if (value == NullOverride) + { + return null; + } + + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + internal async Task GetRequiredApiKeyAsync(CancellationToken cancellationToken = default) + { + var immediateApiKey = TryGetImmediateApiKey(); + if (immediateApiKey != null) + { + return immediateApiKey; + } + + if (_hasApiKeyOverride || Environment.GetEnvironmentVariable(ApiKeySettingName) == NullOverride) + { + throw MissingApiKeyException(); + } + + var apiKey = await BraintrustApiKeyDiscovery.FindInBraintrustEnvFileAsync( + _braintrustEnvSearchRoot, + cancellationToken) + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + return apiKey; + } + + throw MissingApiKeyException(); + } + + private static InvalidOperationException MissingApiKeyException() + { + return new InvalidOperationException( + "BRAINTRUST_API_KEY is required. Set BRAINTRUST_API_KEY, define it in .env.braintrust, or provide an API key."); + } + /// /// The parent attribute tells Braintrust where to send otel data. /// diff --git a/src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs b/src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs new file mode 100644 index 0000000..3b150cb --- /dev/null +++ b/src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs @@ -0,0 +1,45 @@ +using System.Net.Http.Headers; +using Braintrust.Sdk.Config; + +namespace Braintrust.Sdk.Trace; + +internal sealed class BraintrustOtlpAuthHandler : DelegatingHandler +{ + private readonly BraintrustConfig _config; + + internal BraintrustOtlpAuthHandler(BraintrustConfig config) + { + _config = config; + } + + protected override HttpResponseMessage Send( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + ApplyBraintrustHeadersAsync(request, cancellationToken).GetAwaiter().GetResult(); + return base.Send(request, cancellationToken); + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + await ApplyBraintrustHeadersAsync(request, cancellationToken).ConfigureAwait(false); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async Task ApplyBraintrustHeadersAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var apiKey = await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + var parentValue = _config.GetBraintrustParentValue(); + if (parentValue != null) + { + request.Headers.Remove("x-bt-parent"); + request.Headers.TryAddWithoutValidation("x-bt-parent", parentValue); + } + } +} diff --git a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs index 86ca4fa..739679e 100644 --- a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs +++ b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs @@ -82,8 +82,14 @@ public static void Enable(BraintrustConfig config, TracerProviderBuilder tracerP { otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf; otlpOptions.Endpoint = new Uri($"{config.ApiUrl}{config.TracesPath}"); - otlpOptions.Headers = BuildHeaders(config); otlpOptions.TimeoutMilliseconds = (int)config.RequestTimeout.TotalMilliseconds; + otlpOptions.HttpClientFactory = () => new HttpClient(new BraintrustOtlpAuthHandler(config) + { + InnerHandler = new HttpClientHandler() + }) + { + Timeout = config.RequestTimeout + }; }) .SetSampler(new AlwaysOnSampler()); } @@ -104,20 +110,4 @@ public static ActivitySource GetActivitySource() return _activitySource.Value; } - private static string BuildHeaders(BraintrustConfig config) - { - var headers = new List - { - $"Authorization=Bearer {config.ApiKey}" - }; - - // Add parent header if available - var parentValue = config.GetBraintrustParentValue(); - if (parentValue != null) - { - headers.Add($"x-bt-parent={parentValue}"); - } - - return string.Join(",", headers); - } } diff --git a/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs b/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs index 60e7c25..706f63c 100644 --- a/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs +++ b/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs @@ -6,6 +6,7 @@ namespace Braintrust.Sdk.Tests.Api; +[Collection("BraintrustGlobals")] public class BraintrustApiClientTest : IDisposable { private readonly TestHttpMessageHandler _handler; @@ -128,6 +129,44 @@ public async Task ApiException_ThrownOn_HttpError() Assert.Contains("400", exception.Message); } + [Fact] + public async Task UsesApiKeyFromBraintrustEnvFile() + { + var originalCwd = Directory.GetCurrentDirectory(); + var originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + var tempDir = Directory.CreateTempSubdirectory("braintrust-api-client-env-").FullName; + + try + { + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + File.WriteAllText(Path.Combine(tempDir, ".env.braintrust"), "BRAINTRUST_API_KEY=file-api-key\n"); + Directory.SetCurrentDirectory(tempDir); + + using var handler = new TestHttpMessageHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://test-api.example.com") + }; + + var config = BraintrustConfig.Of( + ("BRAINTRUST_API_URL", "https://test-api.example.com"), + ("BRAINTRUST_DEFAULT_PROJECT_NAME", "test-project") + ); + using var apiClient = new BraintrustApiClient(config, httpClient); + handler.SetResponse(HttpStatusCode.OK, new Project("proj-123", "test-project", "org-456")); + + await apiClient.GetOrCreateProject("test-project"); + + Assert.Equal("Bearer file-api-key", handler.LastRequest?.Headers.Authorization?.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); + Directory.Delete(tempDir, recursive: true); + } + } + // Test HttpMessageHandler for mocking HTTP responses private class TestHttpMessageHandler : HttpMessageHandler { diff --git a/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs b/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs new file mode 100644 index 0000000..7242d02 --- /dev/null +++ b/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs @@ -0,0 +1,214 @@ +using Braintrust.Sdk.Config; + +namespace Braintrust.Sdk.Tests.Config; + +[Collection("BraintrustGlobals")] +public class BraintrustApiKeyDiscoveryTest : IDisposable +{ + private readonly string _originalCwd; + private readonly string? _originalApiKey; + private readonly string _tempDir; + + public BraintrustApiKeyDiscoveryTest() + { + _originalCwd = Directory.GetCurrentDirectory(); + _originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + _tempDir = Directory.CreateTempSubdirectory("braintrust-env-").FullName; + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalCwd); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", _originalApiKey); + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public async Task FindsApiKeyInNearestParentBraintrustEnv() + { + var nested = Path.Combine(_tempDir, "packages", "app"); + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=parent-key\n"); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("parent-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task UsesNearestBraintrustEnvInsteadOfHigherParent() + { + var nested = Path.Combine(_tempDir, "packages", "app"); + var packageDir = Path.GetDirectoryName(nested)!; + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=root-key\n"); + WriteBraintrustEnv(packageDir, "BRAINTRUST_API_KEY=package-key\n"); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("package-key", await config.GetRequiredApiKeyAsync()); + } + + [Theory] + [InlineData("OTHER=value\n")] + [InlineData("BRAINTRUST_API_KEY=\" \"\n")] + public async Task StopsAtNearestBraintrustEnvWhenApiKeyIsMissingOrBlank(string nearestContents) + { + var nested = Path.Combine(_tempDir, "packages", "app"); + var packageDir = Path.GetDirectoryName(nested)!; + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=root-key\n"); + WriteBraintrustEnv(packageDir, nearestContents); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + var exception = await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); + } + + [Fact] + public async Task UsesProcessEnvironmentBeforeBraintrustEnv() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "env-key"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("env-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task FallsBackToBraintrustEnvWhenProcessEnvironmentIsBlank() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", " "); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("file-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task ExplicitApiKeyOverrideWinsOverEnvironmentAndBraintrustEnv() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "env-key"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.Of(("BRAINTRUST_API_KEY", "explicit-key")); + + Assert.Equal("explicit-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task ExplicitBlankApiKeyOverrideDoesNotFallBack() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "env-key"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.Of(("BRAINTRUST_API_KEY", " ")); + var exception = await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); + } + + [Fact] + public async Task SearchesCwdAndAtMost64ParentDirectories() + { + var segments = Enumerable.Range(0, 65).Select(i => $"d{i}").ToArray(); + var nested = Path.Combine(new[] { _tempDir }.Concat(segments).ToArray()); + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=too-high\n"); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + WriteBraintrustEnv(Path.Combine(_tempDir, segments[0]), "BRAINTRUST_API_KEY=boundary-key\n"); + + Assert.Equal("boundary-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task SupportsDotenvSyntaxWithoutMutatingEnvironment() + { + WriteBraintrustEnv( + _tempDir, + "OTHER=value\nexport BRAINTRUST_API_KEY=\"quoted-key\" # comment\n"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("quoted-key", await config.GetRequiredApiKeyAsync()); + Assert.Null(Environment.GetEnvironmentVariable("OTHER")); + Assert.Null(Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY")); + } + + [Fact] + public async Task UnreadableNearestBraintrustEnvDoesNotCheckHigherParents() + { + var nested = Path.Combine(_tempDir, "packages", "app"); + var packageDir = Path.GetDirectoryName(nested)!; + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=root-key\n"); + Directory.CreateDirectory(Path.Combine(packageDir, ".env.braintrust")); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + var exception = await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); + } + + [Fact] + public async Task CancellationDuringBraintrustEnvLookupIsPropagated() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Directory.SetCurrentDirectory(_tempDir); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var config = BraintrustConfig.FromEnvironment(); + + await Assert.ThrowsAsync(() => + config.GetRequiredApiKeyAsync(cancellation.Token)); + } + + [Fact] + public async Task ConfigCreationDoesNotReadBraintrustEnvUntilApiKeyLookup() + { + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=late-file-key\n"); + + Assert.Equal("late-file-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task UsesBraintrustEnvSearchRootFromConfigCreation() + { + var lookupDir = Path.Combine(_tempDir, "lookup"); + Directory.CreateDirectory(lookupDir); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=creation-key\n"); + WriteBraintrustEnv(lookupDir, "BRAINTRUST_API_KEY=lookup-key\n"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + Directory.SetCurrentDirectory(lookupDir); + + Assert.Equal("creation-key", await config.GetRequiredApiKeyAsync()); + } + + private static void WriteBraintrustEnv(string dir, string contents) + { + File.WriteAllText(Path.Combine(dir, ".env.braintrust"), contents); + } +} diff --git a/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs b/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs index ef35a48..642a38f 100644 --- a/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs +++ b/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs @@ -2,6 +2,7 @@ namespace Braintrust.Sdk.Tests.Config; +[Collection("BraintrustGlobals")] public class BraintrustConfigTest { [Fact] @@ -31,9 +32,8 @@ public void ParentUsesProjectId() [Fact] public void RequiresApiKey() { - var exception = Assert.Throws(() => - BraintrustConfig.Of( - ("BRAINTRUST_API_KEY", BaseConfig.NullOverride))); + var config = BraintrustConfig.Of(("BRAINTRUST_API_KEY", BaseConfig.NullOverride)); + var exception = Assert.Throws(() => _ = config.ApiKey); Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); } diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs index bc894a7..cae98c2 100644 --- a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs +++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs @@ -27,4 +27,42 @@ public void GetActivitySource_ReturnsActivitySource() Assert.NotNull(activitySource); Assert.Equal("braintrust-dotnet", activitySource.Name); } + + [Fact] + public void CreateTracerProvider_DoesNotRequireApiKeyDuringSetup() + { + var originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + + try + { + var config = BraintrustConfig.FromEnvironment(); + using var tracerProvider = BraintrustTracing.CreateTracerProvider(config); + + Assert.NotNull(tracerProvider); + } + finally + { + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); + } + } + + [Fact] + public void ForceFlushBeforeExport_DoesNotRequireApiKey() + { + var originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + + try + { + var config = BraintrustConfig.FromEnvironment(); + using var tracerProvider = BraintrustTracing.CreateTracerProvider(config); + + Assert.True(BraintrustTracing.ForceFlush()); + } + finally + { + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); + } + } }