diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs index f38efa24c02..3b1986364de 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs @@ -6,10 +6,6 @@ var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini"); -// To set the GitHub Models API key define the value for the following parameter in User Secrets. -// Alternatively, you can set the environment variable GITHUB_TOKEN and comment the line below. -chat.WithApiKey(builder.AddParameter("github-api-key", secret: true)); - builder.AddProject("webstory") .WithExternalHttpEndpoints() .WithReference(chat).WaitFor(chat); diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs index 6ceca9e8ebb..a6c441e38ac 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs @@ -16,10 +16,12 @@ public class GitHubModelResource : Resource, IResourceWithConnectionString, IRes /// The name of the resource. /// The model name. /// The organization. - public GitHubModelResource(string name, string model, ParameterResource? organization) : base(name) + /// The key parameter. + public GitHubModelResource(string name, string model, ParameterResource? organization, ParameterResource key) : base(name) { Model = model; Organization = organization; + Key = key; } /// @@ -39,10 +41,9 @@ public GitHubModelResource(string name, string model, ParameterResource? organiz /// Gets or sets the API key (PAT or GitHub App minted token) for accessing GitHub Models. /// /// - /// If not set, the value will be retrieved from the environment variable GITHUB_TOKEN. /// The token must have the models: read permission if using a fine-grained PAT or GitHub App minted token. /// - public ParameterResource Key { get; set; } = new ParameterResource("github-api-key", p => Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? string.Empty, secret: true); + public ParameterResource Key { get; internal set; } /// /// Gets the connection string expression for the GitHub Models resource. diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index 27e5f2af9c8..7b68c6ac2d2 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -27,7 +27,15 @@ public static IResourceBuilder AddGitHubModel(this IDistrib ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(model); - var resource = new GitHubModelResource(name, model, organization?.Resource); + var defaultApiKeyParameter = builder.AddParameter($"{name}-gh-apikey", () => + builder.Configuration[$"Parameters:{name}-gh-apikey"] ?? + Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? + throw new MissingParameterValueException($"GitHub API key parameter '{name}-gh-apikey' is missing and GITHUB_TOKEN environment variable is not set."), + secret: true); + + var resource = new GitHubModelResource(name, model, organization?.Resource, defaultApiKeyParameter.Resource); + + defaultApiKeyParameter.WithParentRelationship(resource); return builder.AddResource(resource) .WithInitialState(new() @@ -66,11 +74,20 @@ await evt.Eventing.PublishAsync(new ConnectionStringAvailableEvent(r, evt.Servic /// The resource builder. /// The API key parameter. /// The resource builder. + /// Thrown when the provided parameter is not marked as secret. public static IResourceBuilder WithApiKey(this IResourceBuilder builder, IResourceBuilder apiKey) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(apiKey); + if (!apiKey.Resource.Secret) + { + throw new ArgumentException("The API key parameter must be marked as secret. Use AddParameter with secret: true when creating the parameter.", nameof(apiKey)); + } + + // Remove the existing API key parameter + builder.ApplicationBuilder.Resources.Remove(builder.Resource.Key); + builder.Resource.Key = apiKey.Resource; return builder; diff --git a/src/Aspire.Hosting.GitHub.Models/README.md b/src/Aspire.Hosting.GitHub.Models/README.md index f552b504e00..018cae72e7d 100644 --- a/src/Aspire.Hosting.GitHub.Models/README.md +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -24,10 +24,7 @@ Then, in the _AppHost.cs_ file of `AppHost`, add a GitHub Model resource and con ```csharp var builder = DistributedApplication.CreateBuilder(args); -var apiKey = builder.AddParameter("github-api-key", secret: true); - -var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") - .WithApiKey(apiKey); +var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini"); var myService = builder.AddProject() .WithReference(chat); @@ -49,13 +46,7 @@ The GitHub Model resource can be configured with the following options: ### API Key -The API key can be configured using a parameter: - -```csharp -var apiKey = builder.AddParameter("github-api-key", secret: true); -var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") - .WithApiKey(apiKey); -``` +The API key can be set as a configuration value using the default name `{resource_name}-gh-apikey` or the `GITHUB_TOKEN` environment variable. Then in user secrets: @@ -63,16 +54,28 @@ Then in user secrets: { "Parameters": { - "github-api-key": "YOUR_GITHUB_TOKEN_HERE" + "chat-gh-apikey": "YOUR_GITHUB_TOKEN_HERE" } } ``` -Or directly as a string (not recommended for production): +Furthermore, the API key can be configured using a custom parameter: ```csharp +var apiKey = builder.AddParameter("my-api-key", secret: true); var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") - .WithApiKey("your-api-key-here"); + .WithApiKey(apiKey); +``` + +Then in user secrets: + +```json +{ + "Parameters": + { + "my-api-key": "YOUR_GITHUB_TOKEN_HERE" + } +} ``` ## Available Models diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index 5fd56f65870..0ea7e0e2951 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -12,6 +12,7 @@ public class GitHubModelsExtensionTests public void AddGitHubModelAddsResourceWithCorrectName() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); @@ -19,10 +20,24 @@ public void AddGitHubModelAddsResourceWithCorrectName() Assert.Equal("openai/gpt-4o-mini", github.Resource.Model); } + [Fact] + public void AddGitHubModelCreatesDefaultApiKeyParameter() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("mymodel", "openai/gpt-4o-mini"); + + // Verify that the API key parameter exists and follows the naming pattern + Assert.NotNull(github.Resource.Key); + Assert.Equal("mymodel-gh-apikey", github.Resource.Key.Name); + Assert.True(github.Resource.Key.Secret); + } + [Fact] public void AddGitHubModelUsesCorrectEndpoint() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); @@ -34,6 +49,7 @@ public void AddGitHubModelUsesCorrectEndpoint() public void ConnectionStringExpressionIsCorrectlyFormatted() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); @@ -72,7 +88,7 @@ public void DefaultKeyParameterIsCreated() var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); Assert.NotNull(github.Resource.Key); - Assert.Equal("github-api-key", github.Resource.Key.Name); + Assert.Equal("github-gh-apikey", github.Resource.Key.Name); Assert.True(github.Resource.Key.Secret); } @@ -80,6 +96,7 @@ public void DefaultKeyParameterIsCreated() public void AddGitHubModelWithoutOrganization() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); @@ -93,6 +110,7 @@ public void AddGitHubModelWithOrganization() var orgParameter = builder.AddParameter("github-org"); builder.Configuration["Parameters:github-org"] = "myorg"; + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini", orgParameter); @@ -108,6 +126,7 @@ public void ConnectionStringExpressionWithOrganization() var orgParameter = builder.AddParameter("github-org"); builder.Configuration["Parameters:github-org"] = "myorg"; + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini", orgParameter); @@ -127,6 +146,7 @@ public async Task ConnectionStringExpressionWithOrganizationResolvesCorrectly() var orgParameter = builder.AddParameter("github-org"); builder.Configuration["Parameters:github-org"] = "myorg"; + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini", orgParameter); @@ -141,6 +161,7 @@ public async Task ConnectionStringExpressionWithOrganizationResolvesCorrectly() public void ConnectionStringExpressionWithoutOrganization() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); @@ -160,21 +181,31 @@ public void GitHubModelResourceConstructorSetsOrganization() var orgParameter = builder.AddParameter("github-org"); builder.Configuration["Parameters:github-org"] = "myorg"; - var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", orgParameter.Resource); + var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); + builder.Configuration["Parameters:github-api-key"] = "test-key"; + + var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", orgParameter.Resource, apiKeyParameter.Resource); Assert.Equal("test", resource.Name); Assert.Equal("openai/gpt-4o-mini", resource.Model); Assert.Equal(orgParameter.Resource, resource.Organization); + Assert.Equal(apiKeyParameter.Resource, resource.Key); } [Fact] public void GitHubModelResourceConstructorWithNullOrganization() { - var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", null); + using var builder = TestDistributedApplicationBuilder.Create(); + + var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); + builder.Configuration["Parameters:github-api-key"] = "test-key"; + + var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", null, apiKeyParameter.Resource); Assert.Equal("test", resource.Name); Assert.Equal("openai/gpt-4o-mini", resource.Model); Assert.Null(resource.Organization); + Assert.Equal(apiKeyParameter.Resource, resource.Key); } [Fact] @@ -185,17 +216,49 @@ public void GitHubModelResourceOrganizationCanBeChanged() var orgParameter = builder.AddParameter("github-org"); builder.Configuration["Parameters:github-org"] = "myorg"; - var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", null); + var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); + builder.Configuration["Parameters:github-api-key"] = "test-key"; + + var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", null, apiKeyParameter.Resource); Assert.Null(resource.Organization); resource.Organization = orgParameter.Resource; Assert.Equal(orgParameter.Resource, resource.Organization); } + [Fact] + public void WithApiKeyThrowsIfParameterIsNotSecret() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + var apiKey = builder.AddParameter("non-secret-key"); // Not marked as secret + + var exception = Assert.Throws(() => github.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:github-gh-apikey"] = "test-api-key"; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + var apiKey = builder.AddParameter("secret-key", secret: true); + + // This should not throw + var result = github.WithApiKey(apiKey); + Assert.NotNull(result); + Assert.Equal(apiKey.Resource, github.Resource.Key); + } + [Fact] public void WithHealthCheckAddsHealthCheckAnnotation() { using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:github-gh-apikey"] = "test-api-key"; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini").WithHealthCheck();