File: OpenAIExtensionTests.cs
Web Access
Project: src\tests\Aspire.Hosting.OpenAI.Tests\Aspire.Hosting.OpenAI.Tests.csproj (Aspire.Hosting.OpenAI.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.OpenAI.Tests;
 
public class OpenAIExtensionTests
{
    [Fact]
    public async Task DefaultEndpointIsUsedInConnectionString()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "sk-default";
 
        var parent = builder.AddOpenAI("openai");
        var model = parent.AddModel("chat", "gpt-4o-mini");
 
        var parentCs = await parent.Resource.ConnectionStringExpression.GetValueAsync(default);
        var modelCs = await model.Resource.ConnectionStringExpression.GetValueAsync(default);
 
        Assert.Contains("Endpoint=https://api.openai.com/v1", parentCs);
        Assert.Contains("Key=sk-default", parentCs);
 
        Assert.Contains("Endpoint=https://api.openai.com/v1", modelCs);
        Assert.Contains("Key=sk-default", modelCs);
        Assert.Contains("Model=gpt-4o-mini", modelCs);
    }
 
    [Fact]
    public async Task WithEndpointUpdatesParentAndModelConnectionStrings()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "sk-custom";
 
        var parent = builder.AddOpenAI("openai").WithEndpoint("https://my-gateway.example.com/v1");
        var model = parent.AddModel("chat", "gpt-4o-mini");
 
        var parentCs = await parent.Resource.ConnectionStringExpression.GetValueAsync(default);
        var modelCs = await model.Resource.ConnectionStringExpression.GetValueAsync(default);
 
        Assert.Contains("Endpoint=https://my-gateway.example.com/v1", parentCs);
        Assert.Contains("Key=sk-custom", parentCs);
 
        Assert.Contains("Endpoint=https://my-gateway.example.com/v1", modelCs);
        Assert.Contains("Key=sk-custom", modelCs);
        Assert.Contains("Model=gpt-4o-mini", modelCs);
    }
 
    [Fact]
    public void AddOpenAIAddsParentAndModelWithCorrectName()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
    var parent = builder.AddOpenAI("openai");
    var model = parent.AddModel("chat", "gpt-4o-mini");
 
    Assert.Equal("chat", model.Resource.Name);
        Assert.Equal("gpt-4o-mini", model.Resource.Model);
    }
 
    [Fact]
    public void AddOpenAICreatesDefaultApiKeyParameterOnParent()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var parent = builder.AddOpenAI("openai");
        var model = parent.AddModel("chat", "gpt-4o-mini");
 
        Assert.NotNull(parent.Resource.Key);
        Assert.Equal("openai-openai-apikey", parent.Resource.Key.Name);
        Assert.True(parent.Resource.Key.Secret);
        // Model composes from parent; parent key exists
        Assert.NotNull(parent.Resource.Key);
    }
 
    [Fact]
    public async Task AddOpenAIModelUsesCorrectEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
    var parent = builder.AddOpenAI("openai");
    var openai = parent.AddModel("chat", "gpt-4o-mini");
 
    // Expression should reference the parent connection string and append the model
    var expression = openai.Resource.ConnectionStringExpression.ValueExpression;
    Assert.Contains("{openai.connectionString}", expression);
    Assert.Contains(";Model=gpt-4o-mini", expression);
 
    // Resolved value includes the default endpoint
    var resolved = await openai.Resource.ConnectionStringExpression.GetValueAsync(default);
    Assert.Contains("Endpoint=https://api.openai.com/v1", resolved);
    }
 
    [Fact]
    public async Task ConnectionStringExpressionIsCorrectlyFormatted()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
    var parent = builder.AddOpenAI("openai");
    var openai = parent.AddModel("chat", "gpt-4o-mini");
 
    var expression = openai.Resource.ConnectionStringExpression.ValueExpression;
 
    // Expression uses parent reference; resolved value contains the details
    Assert.Contains("{openai.connectionString}", expression);
    Assert.Contains(";Model=gpt-4o-mini", expression);
 
    var resolved = await openai.Resource.ConnectionStringExpression.GetValueAsync(default);
    Assert.Contains("Endpoint=https://api.openai.com/v1", resolved);
    Assert.Contains("Model=gpt-4o-mini", resolved);
    Assert.Contains("Key=", resolved);
    }
 
    [Fact]
    public async Task WithApiKeySetFromParameter()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        const string apiKey = "sk-test-key";
 
        var apiKeyParameter = builder.AddParameter("openai-api-key", secret: true);
        builder.Configuration["Parameters:openai-api-key"] = apiKey;
 
        var parent = builder.AddOpenAI("openai");
        var openai = parent.AddModel("chat", "gpt-4o-mini");
        parent.WithApiKey(apiKeyParameter);
 
        var connectionString = await openai.Resource.ConnectionStringExpression.GetValueAsync(default);
 
    Assert.Equal(apiKeyParameter.Resource, parent.Resource.Key);
        Assert.Contains($"Key={apiKey}", connectionString);
    }
 
    [Fact]
    public void DefaultKeyParameterIsCreated()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
    var parent = builder.AddOpenAI("openai");
    var openai = parent.AddModel("chat", "gpt-4o-mini");
 
    Assert.NotNull(parent.Resource.Key);
    Assert.Equal("openai-openai-apikey", parent.Resource.Key.Name);
    Assert.True(parent.Resource.Key.Secret);
    Assert.Equal(parent.Resource.Key, parent.Resource.Key);
    }
 
    [Fact]
    public void OpenAIModelResourceConstructor()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
    var apiKeyParameter = builder.AddParameter("openai-api-key", secret: true);
    builder.Configuration["Parameters:openai-api-key"] = "test-key";
 
    var parent = builder.AddOpenAI("openai");
    var resource = new OpenAIModelResource("test", "gpt-4o-mini", parent.Resource);
 
        Assert.Equal("test", resource.Name);
        Assert.Equal("gpt-4o-mini", resource.Model);
    // Key is owned by the parent now; the model does not store its own key
    Assert.Equal(parent.Resource.Key, parent.Resource.Key);
    }
 
    [Fact]
    public void WithApiKeyThrowsIfParameterIsNotSecret()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
        var parent = builder.AddOpenAI("openai");
        var openai = parent.AddModel("chat", "gpt-4o-mini");
        var apiKey = builder.AddParameter("non-secret-key"); // Not marked as secret
 
        var exception = Assert.Throws<ArgumentException>(() => parent.WithApiKey(apiKey));
        Assert.Contains("The API key parameter must be marked as secret", exception.Message);
    }
 
    [Fact]
    public void WithApiKeySucceedsIfParameterIsSecret()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
        var parent = builder.AddOpenAI("openai");
        var openai = parent.AddModel("chat", "gpt-4o-mini");
        var apiKey = builder.AddParameter("secret-key", secret: true);
 
        // This should not throw
        var result = parent.WithApiKey(apiKey);
        Assert.NotNull(result);
        Assert.Equal(apiKey.Resource, parent.Resource.Key);
    }
 
    [Fact]
    public void WithApiKeyCalledTwiceOnParentReplacesKeyAndRemovesDefaultParameter()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var parent = builder.AddOpenAI("openai");
        var openai = parent.AddModel("chat", "gpt-4o-mini");
 
        Assert.NotNull(builder.Resources.FirstOrDefault(r => r.Name == "openai-openai-apikey"));
 
        parent.WithApiKey(builder.AddParameter("secret-key1", secret: true));
 
        // Parent override removes the default parameter from the graph
        Assert.Null(builder.Resources.FirstOrDefault(r => r.Name == "openai-openai-apikey"));
        Assert.NotNull(builder.Resources.FirstOrDefault(r => r.Name == "secret-key1"));
 
        parent.WithApiKey(builder.AddParameter("secret-key2", secret: true));
 
        Assert.NotNull(builder.Resources.FirstOrDefault(r => r.Name == "secret-key1"));
        Assert.NotNull(builder.Resources.FirstOrDefault(r => r.Name == "secret-key2"));
    }
 
    [Fact]
    public void WithHealthCheckAddsHealthCheckAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
    var openai = builder.AddOpenAI("openai").AddModel("chat", "gpt-4o-mini").WithHealthCheck();
 
    // Verify that the health check annotation is added
        var healthCheckAnnotations = openai.Resource.Annotations.OfType<HealthCheckAnnotation>().ToList();
        Assert.Single(healthCheckAnnotations);
    Assert.Equal("chat_check", healthCheckAnnotations[0].Key);
    }
 
    [Fact]
    public void WithHealthCheckEnsuresIHttpClientFactoryIsRegistered()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
        // Add the health check without explicitly calling AddHttpClient
    var openai = builder.AddOpenAI("openai").AddModel("chat", "gpt-4o-mini").WithHealthCheck();
 
        // Build the service provider to test dependency resolution
        var services = builder.Services.BuildServiceProvider();
 
        // This should not throw because WithHealthCheck should ensure IHttpClientFactory is registered
        var httpClientFactory = services.GetService<IHttpClientFactory>();
        Assert.NotNull(httpClientFactory);
    }
 
    [Fact]
    public void WithHealthCheckWorksWhenAddHttpClientIsCalledManually()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key";
 
        // Manually call AddHttpClient (should not conflict with automatic registration in WithHealthCheck)
        builder.Services.AddHttpClient();
 
        // Add the health check
    var openai = builder.AddOpenAI("openai").AddModel("chat", "gpt-4o-mini").WithHealthCheck();
 
        // Build the service provider to test dependency resolution
        var services = builder.Services.BuildServiceProvider();
 
        // This should work fine since AddHttpClient can be called multiple times safely
        var httpClientFactory = services.GetService<IHttpClientFactory>();
        Assert.NotNull(httpClientFactory);
    }
 
    [Fact]
    public async Task ConnectionStringExpressionResolves()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:openai-openai-apikey"] = "sk-test123";
 
    var openai = builder.AddOpenAI("openai").AddModel("chat", "gpt-4o-mini");
 
        var connectionString = await openai.Resource.ConnectionStringExpression.GetValueAsync(default);
 
        Assert.Contains("Endpoint=https://api.openai.com/v1", connectionString);
        Assert.Contains("Key=sk-test123", connectionString);
        Assert.Contains("Model=gpt-4o-mini", connectionString);
    }
 
    [Fact]
    public void AddOpenAIThrowsIfBuilderIsNull()
    {
        Assert.Throws<ArgumentNullException>(() => 
            OpenAIExtensions.AddOpenAI(null!, "test"));
    }
 
    [Fact]
    public void AddOpenAIThrowsIfNameIsNullOrEmpty()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        Assert.ThrowsAny<ArgumentException>(() => 
            builder.AddOpenAI(""));
        
        Assert.ThrowsAny<ArgumentException>(() => 
            builder.AddOpenAI(null!));
    }
 
    [Fact]
    public void AddModelThrowsIfModelIsNullOrEmpty()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var parent = builder.AddOpenAI("test");
 
        Assert.ThrowsAny<ArgumentException>(() => 
            parent.AddModel("model", ""));
        
        Assert.ThrowsAny<ArgumentException>(() => 
            parent.AddModel("model", null!));
    }
 
    [Fact]
    public void WithApiKeyThrowsIfBuilderIsNull()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var apiKey = builder.AddParameter("test", secret: true);
 
        Assert.Throws<ArgumentNullException>(() =>
            Aspire.Hosting.OpenAIExtensions.WithApiKey((Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.OpenAI.OpenAIResource>)null!, apiKey));
    }
 
    [Fact]
    public void WithApiKeyThrowsIfApiKeyIsNull()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var parent = builder.AddOpenAI("test");
 
        Assert.Throws<ArgumentNullException>(() => 
            parent.WithApiKey(null!));
    }
 
    [Fact]
    public void WithHealthCheckThrowsIfBuilderIsNull()
    {
        Assert.Throws<ArgumentNullException>(() => 
            OpenAIExtensions.WithHealthCheck(null!));
    }
 
    [Theory]
    [InlineData("gpt-4o-mini")]
    [InlineData("gpt-4o")]
    [InlineData("gpt-4-turbo")]
    [InlineData("gpt-3.5-turbo")]
    [InlineData("text-embedding-3-small")]
    [InlineData("dall-e-3")]
    public void AddOpenAIModelWorksWithDifferentModels(string modelName)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration[$"Parameters:test-openai-apikey"] = "test-key";
 
    var openai = builder.AddOpenAI("test").AddModel("chat", modelName);
 
    Assert.Equal("chat", openai.Resource.Name);
        Assert.Equal(modelName, openai.Resource.Model);
        
        var connectionString = openai.Resource.ConnectionStringExpression.ValueExpression;
        Assert.Contains($"Model={modelName}", connectionString);
    }
}