From 84a200ddf304e4f768138d0962c415be2fe0a2ad Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 1 Jul 2025 18:08:08 +0200 Subject: [PATCH 01/16] Add GitHub Models integration --- Aspire.slnx | 6 ++ .../GitHubModelsEndToEnd.AppHost.csproj | 22 +++++ .../GitHubModelsEndToEnd.AppHost/Program.cs | 23 +++++ .../Components/App.razor | 22 +++++ .../Components/Layout/MainLayout.razor | 9 ++ .../Components/Layout/MainLayout.razor.css | 18 ++++ .../Components/Pages/Error.razor | 36 +++++++ .../Components/Pages/Home.razor | 40 ++++++++ .../Components/Routes.razor | 6 ++ .../Components/_Imports.razor | 10 ++ .../GitHubModelsEndToEnd.WebStory.csproj | 14 +++ .../GitHubModelsEndToEnd.WebStory/Program.cs | 35 +++++++ .../Properties/launchSettings.json | 38 ++++++++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 9 ++ .../wwwroot/app.css | 3 + .../Aspire.Hosting.GitHub.Models.csproj | 16 +++ .../GitHubModelsExtensions.cs | 77 +++++++++++++++ .../GitHubModelsResource.cs | 49 ++++++++++ src/Aspire.Hosting.GitHub.Models/README.md | 97 +++++++++++++++++++ .../Aspire.Hosting.GitHub.Models.Tests.csproj | 13 +++ .../GitHubModelsExtensionTests.cs | 76 +++++++++++++++ 22 files changed, 627 insertions(+) create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/App.razor create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor.css create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Error.razor create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Home.razor create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Routes.razor create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/_Imports.razor create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Program.cs create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.Development.json create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.json create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/wwwroot/app.css create mode 100644 src/Aspire.Hosting.GitHub.Models/Aspire.Hosting.GitHub.Models.csproj create mode 100644 src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs create mode 100644 src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs create mode 100644 src/Aspire.Hosting.GitHub.Models/README.md create mode 100644 tests/Aspire.Hosting.GitHub.Models.Tests/Aspire.Hosting.GitHub.Models.Tests.csproj create mode 100644 tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs diff --git a/Aspire.slnx b/Aspire.slnx index c5cde364731..2ca3199dfb5 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -49,6 +49,7 @@ + @@ -163,6 +164,10 @@ + + + + @@ -371,6 +376,7 @@ + diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj new file mode 100644 index 00000000000..10e26fdfa45 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + b8e8b8c7-4a45-4b2e-8c7d-9e8f7a6b5c4d + + + + + + + + + + + + + diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs new file mode 100644 index 00000000000..5ca736a7dd0 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") + .WithApiKey("GITHUB_TOKEN"); + +builder.AddProject("webstory") + .WithExternalHttpEndpoints() + .WithReference(chat); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/App.razor b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/App.razor new file mode 100644 index 00000000000..c47bd1723e8 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/App.razor @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + +@code { + IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..4231bc9974c --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor.css b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..df8c10ff299 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Layout/MainLayout.razor.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Error.razor b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Error.razor new file mode 100644 index 00000000000..7474392d99a --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Home.razor b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Home.razor new file mode 100644 index 00000000000..4c34f5d4d2b --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Home.razor @@ -0,0 +1,40 @@ +@page "/" +@using Microsoft.Extensions.AI +@inject IChatClient chatClient +@inject ILogger logger + +
+ @foreach (var message in chatMessages.Where(m => m.Role == ChatRole.Assistant)) + { +

@message.Text

+ } + + + +

+ Powered by GitHub Models +

+
+ +@code { + private List chatMessages = new List + { + new(ChatRole.System, "Pick a random topic and write a sentence of a fictional story about it.") + }; + + private async Task GenerateNextParagraph() + { + if (chatMessages.Count > 1) + { + chatMessages.Add(new ChatMessage(ChatRole.User, "Write the next sentence in the story.")); + } + + var response = await chatClient.GetResponseAsync(chatMessages); + chatMessages.AddMessages(response); + } + + protected override async Task OnInitializedAsync() + { + await GenerateNextParagraph(); + } +} diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Routes.razor b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Routes.razor new file mode 100644 index 00000000000..faa2a8c2d5f --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/_Imports.razor b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/_Imports.razor new file mode 100644 index 00000000000..eb6bbda2438 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using GitHubModelsEndToEnd.WebStory +@using GitHubModelsEndToEnd.WebStory.Components diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj new file mode 100644 index 00000000000..aa19fad6805 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj @@ -0,0 +1,14 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Program.cs b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Program.cs new file mode 100644 index 00000000000..9d2b6f4e730 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Program.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using GitHubModelsEndToEnd.WebStory.Components; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureChatCompletionsClient("chat") + .AddChatClient(); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json new file mode 100644 index 00000000000..8e89d4f7894 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:54245", + "sslPort": 44363 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5293", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7025;http://localhost:5293", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.Development.json b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.json b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/wwwroot/app.css b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/wwwroot/app.css new file mode 100644 index 00000000000..36c7520442f --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/wwwroot/app.css @@ -0,0 +1,3 @@ +p { + font-size: 6em; +} diff --git a/src/Aspire.Hosting.GitHub.Models/Aspire.Hosting.GitHub.Models.csproj b/src/Aspire.Hosting.GitHub.Models/Aspire.Hosting.GitHub.Models.csproj new file mode 100644 index 00000000000..e9ba91d415e --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/Aspire.Hosting.GitHub.Models.csproj @@ -0,0 +1,16 @@ + + + + $(DefaultTargetFramework) + true + aspire integration hosting github models ai + GitHub Models resource types for .NET Aspire. + $(SharedDir)GitHub_256x.png + true + + + + + + + diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs new file mode 100644 index 00000000000..7625ff802e8 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -0,0 +1,77 @@ +// 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.GitHub.Models; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding GitHub Models resources to the application model. +/// +public static class GitHubModelsExtensions +{ + /// + /// Adds a GitHub Models resource to the application model. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The model name to use with GitHub Models. + /// A reference to the . + public static IResourceBuilder AddGitHubModel(this IDistributedApplicationBuilder builder, [ResourceName] string name, string model) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(model); + + var resource = new GitHubModelsResource(name, model); + return builder.AddResource(resource); + } + + /// + /// Configures the endpoint for the GitHub Models resource. + /// + /// The resource builder. + /// The endpoint URL. + /// The resource builder. + public static IResourceBuilder WithEndpoint(this IResourceBuilder builder, string endpoint) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(endpoint); + + builder.Resource.Endpoint = endpoint; + return builder; + } + + /// + /// Configures the API key for the GitHub Models resource. + /// + /// The resource builder. + /// The API key. + /// The resource builder. + public static IResourceBuilder WithApiKey(this IResourceBuilder builder, string key) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(key); + + builder.Resource.Key = key; + return builder; + } + + /// + /// Configures the API key for the GitHub Models resource from a parameter. + /// + /// The resource builder. + /// The API key parameter. + /// The resource builder. + public static IResourceBuilder WithApiKey(this IResourceBuilder builder, IResourceBuilder apiKey) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(apiKey); + + return builder.WithAnnotation(new EnvironmentCallbackAnnotation(context => + { + context.EnvironmentVariables["GITHUB_TOKEN"] = apiKey.Resource; + })); + } +} diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs new file mode 100644 index 00000000000..669565200a4 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs @@ -0,0 +1,49 @@ +// 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; + +namespace Aspire.Hosting.GitHub.Models; + +/// +/// Represents a GitHub Models resource. +/// +public class GitHubModelsResource : Resource, IResourceWithConnectionString +{ + /// + /// The default endpoint for GitHub Models. + /// + public const string DefaultEndpoint = "https://models.github.ai/inference"; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The model name. + public GitHubModelsResource(string name, string model) : base(name) + { + Model = model; + Endpoint = DefaultEndpoint; + } + + /// + /// Gets or sets the endpoint URL for the GitHub Models service. + /// + public string Endpoint { get; set; } + + /// + /// Gets or sets the model name. + /// + public string Model { get; set; } + + /// + /// Gets or sets the API key for accessing GitHub Models. + /// + public string? Key { get; set; } + + /// + /// Gets the connection string expression for the GitHub Models resource. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"Endpoint={Endpoint};Key={Key};Model={Model};DeploymentId={Model}"); +} diff --git a/src/Aspire.Hosting.GitHub.Models/README.md b/src/Aspire.Hosting.GitHub.Models/README.md new file mode 100644 index 00000000000..3a36e64ba88 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -0,0 +1,97 @@ +# Aspire.Hosting.GitHub.Models library + +Provides extension methods and resource definitions for a .NET Aspire AppHost to configure GitHub Models. + +## Getting started + +### Prerequisites + +- GitHub account with access to GitHub Models +- GitHub personal access token with appropriate permissions + +### Install the package + +In your AppHost project, install the .NET Aspire GitHub Models Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.GitHub.Models +``` + +## Usage example + +Then, in the _AppHost.cs_ file of `AppHost`, add a GitHub Models resource and consume the connection using the following methods: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var apiKey = builder.AddParameter("github-api-key", secret: true); + +var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") + .WithApiKey(apiKey); + +var myService = builder.AddProject() + .WithReference(chat); +``` + +The `WithReference` method passes that connection information into a connection string named `chat` in the `MyService` project. + +In the _Program.cs_ file of `MyService`, the connection can be consumed using a client library like [Aspire.Azure.AI.Inference](https://www.nuget.org/packages/Aspire.Azure.AI.Inference): + +#### Inference client usage +```csharp +builder.AddAzureChatCompletionsClient("chat") + .AddChatClient(); +``` + +## Configuration + +The GitHub Models 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", "gpt-4o-mini") + .WithApiKey(apiKey); +``` + +Or directly as a string (not recommended for production): + +```csharp +var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") + .WithApiKey("your-api-key-here"); +``` + +### Custom Endpoint + +You can customize the endpoint if needed: + +```csharp +var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") + .WithEndpoint("https://custom-endpoint.example.com") + .WithApiKey(apiKey); +``` + +## Available Models + +GitHub Models supports various AI models. Some popular options include: + +- `gpt-4o-mini` +- `gpt-4o` +- `claude-3-5-sonnet` +- `llama-3.1-405b-instruct` +- `llama-3.1-70b-instruct` +- `llama-3.1-8b-instruct` + +Check the [GitHub Models documentation](https://docs.github.com/en/github-models) for the most up-to-date list of available models. + +## Additional documentation + +* https://docs.github.com/en/github-models +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/Aspire.Hosting.GitHub.Models.Tests.csproj b/tests/Aspire.Hosting.GitHub.Models.Tests/Aspire.Hosting.GitHub.Models.Tests.csproj new file mode 100644 index 00000000000..ea8a5317934 --- /dev/null +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/Aspire.Hosting.GitHub.Models.Tests.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs new file mode 100644 index 00000000000..b1c7f7cfb66 --- /dev/null +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -0,0 +1,76 @@ +// 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 Xunit; + +namespace Aspire.Hosting.GitHub.Models.Tests; + +public class GitHubModelsExtensionTests +{ + [Fact] + public void AddGitHubModelAddsResourceWithCorrectName() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("github", "gpt-4o-mini"); + + Assert.Equal("github", github.Resource.Name); + Assert.Equal("gpt-4o-mini", github.Resource.Model); + Assert.Equal(GitHubModelsResource.DefaultEndpoint, github.Resource.Endpoint); + } + + [Fact] + public void WithEndpointSetsEndpointCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var customEndpoint = "https://custom.endpoint.com"; + var github = builder.AddGitHubModel("github", "gpt-4o-mini") + .WithEndpoint(customEndpoint); + + Assert.Equal(customEndpoint, github.Resource.Endpoint); + } + + [Fact] + public void WithApiKeySetsKeyCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var apiKey = "test-api-key"; + var github = builder.AddGitHubModel("github", "gpt-4o-mini") + .WithApiKey(apiKey); + + Assert.Equal(apiKey, github.Resource.Key); + } + + [Fact] + public void ConnectionStringExpressionIsCorrectlyFormatted() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("github", "gpt-4o-mini") + .WithApiKey("test-key"); + + var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; + + Assert.Contains("Endpoint=https://models.github.ai/inference", connectionString); + Assert.Contains("Key=test-key", connectionString); + Assert.Contains("Model=gpt-4o-mini", connectionString); + Assert.Contains("DeploymentId=gpt-4o-mini", connectionString); + } + + [Fact] + public void WithApiKeyParameterAddsEnvironmentAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var apiKeyParam = builder.AddParameter("github-api-key", secret: true); + var github = builder.AddGitHubModel("github", "gpt-4o-mini") + .WithApiKey(apiKeyParam); + + var annotations = github.Resource.Annotations.OfType(); + Assert.True(annotations.Any(), "Expected EnvironmentCallbackAnnotation to be added"); + } +} From 5ff877536b522763a07965c3e1a34f6a5b5222b3 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 1 Jul 2025 20:33:25 +0200 Subject: [PATCH 02/16] Fix parameters and doc --- .../GitHubModelsEndToEnd.AppHost/Program.cs | 6 ++- .../Properties/launchSettings.json | 40 +++++++++++++++ .../GitHubModelsExtensions.cs | 5 +- .../GitHubModelsResource.cs | 4 +- src/Aspire.Hosting.GitHub.Models/README.md | 35 +++++++------ .../GitHubModelsExtensionTests.cs | 51 ++++++++++--------- 6 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Properties/launchSettings.json diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs index 5ca736a7dd0..0fa07968177 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs @@ -3,8 +3,10 @@ var builder = DistributedApplication.CreateBuilder(args); -var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") - .WithApiKey("GITHUB_TOKEN"); +var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); + +var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") + .WithApiKey(apiKeyParameter); builder.AddProject("webstory") .WithExternalHttpEndpoints() diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Properties/launchSettings.json b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..23ebc67f21a --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Properties/launchSettings.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15215;http://localhost:15216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16195", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16195", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17038", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index 7625ff802e8..672f43ded26 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -69,9 +69,6 @@ public static IResourceBuilder WithApiKey(this IResourceBu ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(apiKey); - return builder.WithAnnotation(new EnvironmentCallbackAnnotation(context => - { - context.EnvironmentVariables["GITHUB_TOKEN"] = apiKey.Resource; - })); + return WithApiKey(builder, apiKey.Resource.Value); } } diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs index 669565200a4..7eb155955c1 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs @@ -27,12 +27,12 @@ public GitHubModelsResource(string name, string model) : base(name) } /// - /// Gets or sets the endpoint URL for the GitHub Models service. + /// Gets or sets the endpoint URL for the GitHub Models service, e.g., "https://models.github.ai/inference". /// public string Endpoint { get; set; } /// - /// Gets or sets the model name. + /// Gets or sets the model name, e.g., "openai/gpt-4o-mini". /// public string Model { get; set; } diff --git a/src/Aspire.Hosting.GitHub.Models/README.md b/src/Aspire.Hosting.GitHub.Models/README.md index 3a36e64ba88..4bb6543118c 100644 --- a/src/Aspire.Hosting.GitHub.Models/README.md +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -26,7 +26,7 @@ var builder = DistributedApplication.CreateBuilder(args); var apiKey = builder.AddParameter("github-api-key", secret: true); -var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") +var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") .WithApiKey(apiKey); var myService = builder.AddProject() @@ -53,37 +53,36 @@ The API key can be configured using a parameter: ```csharp var apiKey = builder.AddParameter("github-api-key", secret: true); -var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") +var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") .WithApiKey(apiKey); ``` -Or directly as a string (not recommended for production): +The in user secrets: -```csharp -var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") - .WithApiKey("your-api-key-here"); +```json +{ + "Parameters": + { + "github-api-key": "YOUR_GITHUB_TOKEN_HERE" + } +} ``` -### Custom Endpoint - -You can customize the endpoint if needed: +Or directly as a string (not recommended for production): ```csharp -var chat = builder.AddGitHubModel("chat", "gpt-4o-mini") - .WithEndpoint("https://custom-endpoint.example.com") - .WithApiKey(apiKey); +var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") + .WithApiKey("your-api-key-here"); ``` ## Available Models GitHub Models supports various AI models. Some popular options include: -- `gpt-4o-mini` -- `gpt-4o` -- `claude-3-5-sonnet` -- `llama-3.1-405b-instruct` -- `llama-3.1-70b-instruct` -- `llama-3.1-8b-instruct` +- `openai/gpt-4o-mini` +- `openai/gpt-4o` +- `deepseek/DeepSeek-V3-0324` +- `microsoft/Phi-4-mini-instruct` Check the [GitHub Models documentation](https://docs.github.com/en/github-models) for the most up-to-date list of available models. diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index b1c7f7cfb66..1ea067e3ee1 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -1,7 +1,6 @@ // 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 Xunit; @@ -13,11 +12,11 @@ public class GitHubModelsExtensionTests public void AddGitHubModelAddsResourceWithCorrectName() { using var builder = TestDistributedApplicationBuilder.Create(); - - var github = builder.AddGitHubModel("github", "gpt-4o-mini"); - + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + Assert.Equal("github", github.Resource.Name); - Assert.Equal("gpt-4o-mini", github.Resource.Model); + Assert.Equal("openai/gpt-4o-mini", github.Resource.Model); Assert.Equal(GitHubModelsResource.DefaultEndpoint, github.Resource.Endpoint); } @@ -25,11 +24,11 @@ public void AddGitHubModelAddsResourceWithCorrectName() public void WithEndpointSetsEndpointCorrectly() { using var builder = TestDistributedApplicationBuilder.Create(); - + var customEndpoint = "https://custom.endpoint.com"; - var github = builder.AddGitHubModel("github", "gpt-4o-mini") + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") .WithEndpoint(customEndpoint); - + Assert.Equal(customEndpoint, github.Resource.Endpoint); } @@ -37,11 +36,11 @@ public void WithEndpointSetsEndpointCorrectly() public void WithApiKeySetsKeyCorrectly() { using var builder = TestDistributedApplicationBuilder.Create(); - + var apiKey = "test-api-key"; - var github = builder.AddGitHubModel("github", "gpt-4o-mini") + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") .WithApiKey(apiKey); - + Assert.Equal(apiKey, github.Resource.Key); } @@ -49,28 +48,30 @@ public void WithApiKeySetsKeyCorrectly() public void ConnectionStringExpressionIsCorrectlyFormatted() { using var builder = TestDistributedApplicationBuilder.Create(); - - var github = builder.AddGitHubModel("github", "gpt-4o-mini") + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") .WithApiKey("test-key"); - + var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; - + Assert.Contains("Endpoint=https://models.github.ai/inference", connectionString); Assert.Contains("Key=test-key", connectionString); - Assert.Contains("Model=gpt-4o-mini", connectionString); - Assert.Contains("DeploymentId=gpt-4o-mini", connectionString); + Assert.Contains("Model=openai/gpt-4o-mini", connectionString); + Assert.Contains("DeploymentId=openai/gpt-4o-mini", connectionString); } [Fact] - public void WithApiKeyParameterAddsEnvironmentAnnotation() + public void WithApiKeySetFromParameter() { using var builder = TestDistributedApplicationBuilder.Create(); - - var apiKeyParam = builder.AddParameter("github-api-key", secret: true); - var github = builder.AddGitHubModel("github", "gpt-4o-mini") - .WithApiKey(apiKeyParam); - - var annotations = github.Resource.Annotations.OfType(); - Assert.True(annotations.Any(), "Expected EnvironmentCallbackAnnotation to be added"); + + var randomApiKey = $"test-key"; + var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); + builder.Configuration["Parameters:github-api-key"] = randomApiKey; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") + .WithApiKey(apiKeyParameter); + + Assert.Equal(randomApiKey, github.Resource.Key); } } From df30820a67c812ec5596eac4f6262db5d668b967 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 2 Jul 2025 08:31:58 +0200 Subject: [PATCH 03/16] Add package logo --- src/Shared/GitHub_256x.png | Bin 0 -> 17460 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/Shared/GitHub_256x.png diff --git a/src/Shared/GitHub_256x.png b/src/Shared/GitHub_256x.png new file mode 100644 index 0000000000000000000000000000000000000000..3e560d441f385786ac0d62a7c2980b16f4228621 GIT binary patch literal 17460 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 B8#Dj_ literal 0 HcmV?d00001 From b4833388ed7ea50dc82a7d2ac31faee253bde668 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 2 Jul 2025 09:17:20 +0200 Subject: [PATCH 04/16] Support GITHUB_TOKEN env --- .../GitHubModelsEndToEnd.AppHost/Program.cs | 7 +-- .../GitHubModelsExtensions.cs | 34 ++----------- .../GitHubModelsResource.cs | 18 +++---- src/Aspire.Hosting.GitHub.Models/README.md | 2 +- .../GitHubModelsExtensionTests.cs | 49 ++++++++++--------- 5 files changed, 39 insertions(+), 71 deletions(-) diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs index 0fa07968177..4cc98f2d34b 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs @@ -3,10 +3,11 @@ var builder = DistributedApplication.CreateBuilder(args); -var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); +var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini"); -var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini") - .WithApiKey(apiKeyParameter); +// 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() diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index 672f43ded26..12ec25a7b5f 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -28,36 +28,6 @@ public static IResourceBuilder AddGitHubModel(this IDistri return builder.AddResource(resource); } - /// - /// Configures the endpoint for the GitHub Models resource. - /// - /// The resource builder. - /// The endpoint URL. - /// The resource builder. - public static IResourceBuilder WithEndpoint(this IResourceBuilder builder, string endpoint) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrEmpty(endpoint); - - builder.Resource.Endpoint = endpoint; - return builder; - } - - /// - /// Configures the API key for the GitHub Models resource. - /// - /// The resource builder. - /// The API key. - /// The resource builder. - public static IResourceBuilder WithApiKey(this IResourceBuilder builder, string key) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrEmpty(key); - - builder.Resource.Key = key; - return builder; - } - /// /// Configures the API key for the GitHub Models resource from a parameter. /// @@ -69,6 +39,8 @@ public static IResourceBuilder WithApiKey(this IResourceBu ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(apiKey); - return WithApiKey(builder, apiKey.Resource.Value); + builder.Resource.Key = apiKey.Resource; + + return builder; } } diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs index 7eb155955c1..c03ec2cfc9c 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs @@ -10,10 +10,7 @@ namespace Aspire.Hosting.GitHub.Models; /// public class GitHubModelsResource : Resource, IResourceWithConnectionString { - /// - /// The default endpoint for GitHub Models. - /// - public const string DefaultEndpoint = "https://models.github.ai/inference"; + internal const string GitHubModelsEndpoint = "https://models.github.ai/inference"; /// /// Initializes a new instance of the class. @@ -23,14 +20,8 @@ public class GitHubModelsResource : Resource, IResourceWithConnectionString public GitHubModelsResource(string name, string model) : base(name) { Model = model; - Endpoint = DefaultEndpoint; } - /// - /// Gets or sets the endpoint URL for the GitHub Models service, e.g., "https://models.github.ai/inference". - /// - public string Endpoint { get; set; } - /// /// Gets or sets the model name, e.g., "openai/gpt-4o-mini". /// @@ -39,11 +30,14 @@ public GitHubModelsResource(string name, string model) : base(name) /// /// Gets or sets the API key for accessing GitHub Models. /// - public string? Key { get; set; } + /// + /// If not set, the value will be retrieved from the environment variable GITHUB_TOKEN. + /// + public ParameterResource Key { get; set; } = new ParameterResource("github-api-key", p => Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? string.Empty, secret: true); /// /// Gets the connection string expression for the GitHub Models resource. /// public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create($"Endpoint={Endpoint};Key={Key};Model={Model};DeploymentId={Model}"); + ReferenceExpression.Create($"Endpoint={GitHubModelsEndpoint};Key={Key};Model={Model};DeploymentId={Model}"); } diff --git a/src/Aspire.Hosting.GitHub.Models/README.md b/src/Aspire.Hosting.GitHub.Models/README.md index 4bb6543118c..7b97b3ff679 100644 --- a/src/Aspire.Hosting.GitHub.Models/README.md +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -7,7 +7,7 @@ Provides extension methods and resource definitions for a .NET Aspire AppHost to ### Prerequisites - GitHub account with access to GitHub Models -- GitHub personal access token with appropriate permissions +- GitHub [personal access token](https://docs.github.com/en/github-models/use-github-models/prototyping-with-ai-models#experimenting-with-ai-models-using-the-api) with appropriate permissions ### Install the package diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index 1ea067e3ee1..c1e01c6963d 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -17,31 +17,17 @@ public void AddGitHubModelAddsResourceWithCorrectName() Assert.Equal("github", github.Resource.Name); Assert.Equal("openai/gpt-4o-mini", github.Resource.Model); - Assert.Equal(GitHubModelsResource.DefaultEndpoint, github.Resource.Endpoint); } [Fact] - public void WithEndpointSetsEndpointCorrectly() + public void AddGitHubModelUsesCorrectEndpoint() { using var builder = TestDistributedApplicationBuilder.Create(); - var customEndpoint = "https://custom.endpoint.com"; - var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") - .WithEndpoint(customEndpoint); - - Assert.Equal(customEndpoint, github.Resource.Endpoint); - } - - [Fact] - public void WithApiKeySetsKeyCorrectly() - { - using var builder = TestDistributedApplicationBuilder.Create(); - - var apiKey = "test-api-key"; - var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") - .WithApiKey(apiKey); + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); - Assert.Equal(apiKey, github.Resource.Key); + var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; + Assert.Contains("Endpoint=https://models.github.ai/inference", connectionString); } [Fact] @@ -49,15 +35,14 @@ public void ConnectionStringExpressionIsCorrectlyFormatted() { using var builder = TestDistributedApplicationBuilder.Create(); - var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") - .WithApiKey("test-key"); + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; Assert.Contains("Endpoint=https://models.github.ai/inference", connectionString); - Assert.Contains("Key=test-key", connectionString); Assert.Contains("Model=openai/gpt-4o-mini", connectionString); Assert.Contains("DeploymentId=openai/gpt-4o-mini", connectionString); + Assert.Contains("Key=", connectionString); } [Fact] @@ -65,13 +50,29 @@ public void WithApiKeySetFromParameter() { using var builder = TestDistributedApplicationBuilder.Create(); - var randomApiKey = $"test-key"; + const string apiKey = "randomkey"; + var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); - builder.Configuration["Parameters:github-api-key"] = randomApiKey; + builder.Configuration["Parameters:github-api-key"] = apiKey; var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") .WithApiKey(apiKeyParameter); - Assert.Equal(randomApiKey, github.Resource.Key); + var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; + + Assert.Equal(apiKeyParameter.Resource, github.Resource.Key); + Assert.Contains($"Key={apiKey}", connectionString); + } + + [Fact] + public void DefaultKeyParameterIsCreated() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + + Assert.NotNull(github.Resource.Key); + Assert.Equal("github-api-key", github.Resource.Key.Name); + Assert.True(github.Resource.Key.Secret); } } From c3f086fd71195e14807e21f87c6b0f1d350ebfd3 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 2 Jul 2025 09:30:42 +0200 Subject: [PATCH 05/16] Fix test --- .../GitHubModelsExtensionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index c1e01c6963d..124206aa5b3 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -46,7 +46,7 @@ public void ConnectionStringExpressionIsCorrectlyFormatted() } [Fact] - public void WithApiKeySetFromParameter() + public async Task WithApiKeySetFromParameter() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -58,7 +58,7 @@ public void WithApiKeySetFromParameter() var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") .WithApiKey(apiKeyParameter); - var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; + var connectionString = await github.Resource.ConnectionStringExpression.GetValueAsync(default); Assert.Equal(apiKeyParameter.Resource, github.Resource.Key); Assert.Contains($"Key={apiKey}", connectionString); From 6f21fa541656b80d09ef804ef8dfe0490fa21f0b Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 8 Jul 2025 12:54:27 +0200 Subject: [PATCH 06/16] Rename to GithubModel --- ...{GitHubModelsResource.cs => GitHubModelResource.cs} | 8 ++++---- .../GitHubModelsExtensions.cs | 10 +++++----- src/Aspire.Hosting.GitHub.Models/README.md | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) rename src/Aspire.Hosting.GitHub.Models/{GitHubModelsResource.cs => GitHubModelResource.cs} (86%) diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs similarity index 86% rename from src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs rename to src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs index c03ec2cfc9c..e45055e07ea 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsResource.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs @@ -6,18 +6,18 @@ namespace Aspire.Hosting.GitHub.Models; /// -/// Represents a GitHub Models resource. +/// Represents a GitHub Model resource. /// -public class GitHubModelsResource : Resource, IResourceWithConnectionString +public class GitHubModelResource : Resource, IResourceWithConnectionString, IResourceWithoutLifetime { internal const string GitHubModelsEndpoint = "https://models.github.ai/inference"; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The name of the resource. /// The model name. - public GitHubModelsResource(string name, string model) : base(name) + public GitHubModelResource(string name, string model) : base(name) { Model = model; } diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index 12ec25a7b5f..bb2e643e007 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -12,29 +12,29 @@ namespace Aspire.Hosting; public static class GitHubModelsExtensions { /// - /// Adds a GitHub Models resource to the application model. + /// Adds a GitHub Model resource to the application model. /// /// The . /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The model name to use with GitHub Models. /// A reference to the . - public static IResourceBuilder AddGitHubModel(this IDistributedApplicationBuilder builder, [ResourceName] string name, string model) + public static IResourceBuilder AddGitHubModel(this IDistributedApplicationBuilder builder, [ResourceName] string name, string model) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(model); - var resource = new GitHubModelsResource(name, model); + var resource = new GitHubModelResource(name, model); return builder.AddResource(resource); } /// - /// Configures the API key for the GitHub Models resource from a parameter. + /// Configures the API key for the GitHub Model resource from a parameter. /// /// The resource builder. /// The API key parameter. /// The resource builder. - public static IResourceBuilder WithApiKey(this IResourceBuilder builder, IResourceBuilder apiKey) + public static IResourceBuilder WithApiKey(this IResourceBuilder builder, IResourceBuilder apiKey) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(apiKey); diff --git a/src/Aspire.Hosting.GitHub.Models/README.md b/src/Aspire.Hosting.GitHub.Models/README.md index 7b97b3ff679..c1c823039b4 100644 --- a/src/Aspire.Hosting.GitHub.Models/README.md +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -19,7 +19,7 @@ dotnet add package Aspire.Hosting.GitHub.Models ## Usage example -Then, in the _AppHost.cs_ file of `AppHost`, add a GitHub Models resource and consume the connection using the following methods: +Then, in the _AppHost.cs_ file of `AppHost`, add a GitHub Model resource and consume the connection using the following methods: ```csharp var builder = DistributedApplication.CreateBuilder(args); @@ -45,7 +45,7 @@ builder.AddAzureChatCompletionsClient("chat") ## Configuration -The GitHub Models resource can be configured with the following options: +The GitHub Model resource can be configured with the following options: ### API Key From 9f3a02f8a56a412efc490ef21e68f4f5324d6d13 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 8 Jul 2025 12:57:10 +0200 Subject: [PATCH 07/16] Remove iisexpress in launch --- .../Properties/launchSettings.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json index 8e89d4f7894..a23e5daf637 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json @@ -26,13 +26,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } From a602f5b1bb984e9616e5f7029debc41188dfb52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 8 Jul 2025 19:34:32 +0200 Subject: [PATCH 08/16] Update playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj Co-authored-by: Eric Erhardt --- .../GitHubModelsEndToEnd.AppHost.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj index 10e26fdfa45..1e0cbb9d757 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj @@ -15,7 +15,7 @@ - + From ac0bbb3eceb77b71ed68e4baea97b13eaa329566 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 9 Jul 2025 13:32:25 +0200 Subject: [PATCH 09/16] Define running state on github models --- .../GitHubModelsExtensions.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index bb2e643e007..e5186c3be6b 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -25,7 +25,18 @@ public static IResourceBuilder AddGitHubModel(this IDistrib ArgumentException.ThrowIfNullOrEmpty(model); var resource = new GitHubModelResource(name, model); - return builder.AddResource(resource); + + return builder.AddResource(resource) + .WithInitialState(new() + { + ResourceType = "GitHubModel", + CreationTimeStamp = DateTime.UtcNow, + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = + [ + new(CustomResourceKnownProperties.Source, "Github Models") + ] + }); } /// From 82f49dedd81c1aab065ffa2aa9ffb2fdd7b3564f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 10:35:14 +0200 Subject: [PATCH 10/16] Add organization --- .../GitHubModelResource.cs | 21 ++++++++++++++----- .../GitHubModelsExtensions.cs | 5 +++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs index e45055e07ea..6ceca9e8ebb 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs @@ -10,16 +10,16 @@ namespace Aspire.Hosting.GitHub.Models; /// public class GitHubModelResource : Resource, IResourceWithConnectionString, IResourceWithoutLifetime { - internal const string GitHubModelsEndpoint = "https://models.github.ai/inference"; - /// /// Initializes a new instance of the class. /// /// The name of the resource. /// The model name. - public GitHubModelResource(string name, string model) : base(name) + /// The organization. + public GitHubModelResource(string name, string model, ParameterResource? organization) : base(name) { Model = model; + Organization = organization; } /// @@ -28,10 +28,19 @@ public GitHubModelResource(string name, string model) : base(name) public string Model { get; set; } /// - /// Gets or sets the API key for accessing GitHub Models. + /// Gets or sets the organization login associated with the organization to which the request is to be attributed. + /// + /// + /// If set, the token must be attributed to an organization. + /// + public ParameterResource? Organization { get; set; } + + /// + /// 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); @@ -39,5 +48,7 @@ public GitHubModelResource(string name, string model) : base(name) /// Gets the connection string expression for the GitHub Models resource. /// public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create($"Endpoint={GitHubModelsEndpoint};Key={Key};Model={Model};DeploymentId={Model}"); + Organization is not null + ? ReferenceExpression.Create($"Endpoint=https://models.github.ai/orgs/{Organization}/inference;Key={Key};Model={Model};DeploymentId={Model}") + : ReferenceExpression.Create($"Endpoint=https://models.github.ai/inference;Key={Key};Model={Model};DeploymentId={Model}"); } diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index e5186c3be6b..2d6aa3cc201 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -17,14 +17,15 @@ public static class GitHubModelsExtensions /// The . /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The model name to use with GitHub Models. + /// The organization login associated with the organization to which the request is to be attributed. /// A reference to the . - public static IResourceBuilder AddGitHubModel(this IDistributedApplicationBuilder builder, [ResourceName] string name, string model) + public static IResourceBuilder AddGitHubModel(this IDistributedApplicationBuilder builder, [ResourceName] string name, string model, IResourceBuilder? organization = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(model); - var resource = new GitHubModelResource(name, model); + var resource = new GitHubModelResource(name, model, organization?.Resource); return builder.AddResource(resource) .WithInitialState(new() From 271376550cd142049c58cb63fa18e60266c947c9 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 10:42:46 +0200 Subject: [PATCH 11/16] Add tests --- .github/copilot-instructions.md | 2 +- .../GitHubModelsExtensionTests.cs | 116 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 36f1d93e1c0..cca87df6ce4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -55,7 +55,7 @@ When running tests in automated environments (including Copilot agent), **always ```bash # Correct - excludes quarantined tests (use this in automation) -dotnet.sh test tests/Project.Tests/Project.Tests.csproj --filter-not-trait "quarantined=true" +dotnet.sh test tests/Project.Tests/Project.Tests.csproj -- --filter-not-trait "quarantined=true" # For specific test filters, combine with quarantine exclusion dotnet.sh test tests/Project.Tests/Project.Tests.csproj -- --filter "TestName" --filter-not-trait "quarantined=true" diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index 124206aa5b3..64e9d7d45fe 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -75,4 +75,120 @@ public void DefaultKeyParameterIsCreated() Assert.Equal("github-api-key", github.Resource.Key.Name); Assert.True(github.Resource.Key.Secret); } + + [Fact] + public void AddGitHubModelWithoutOrganization() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + + Assert.Null(github.Resource.Organization); + } + + [Fact] + public void AddGitHubModelWithOrganization() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var orgParameter = builder.AddParameter("github-org"); + builder.Configuration["Parameters:github-org"] = "myorg"; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini", orgParameter); + + Assert.NotNull(github.Resource.Organization); + Assert.Equal("github-org", github.Resource.Organization.Name); + Assert.Equal(orgParameter.Resource, github.Resource.Organization); + } + + [Fact] + public void ConnectionStringExpressionWithOrganization() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var orgParameter = builder.AddParameter("github-org"); + builder.Configuration["Parameters:github-org"] = "myorg"; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini", orgParameter); + + var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; + + Assert.Contains("Endpoint=https://models.github.ai/orgs/", connectionString); + Assert.Contains("/inference", connectionString); + Assert.Contains("Model=openai/gpt-4o-mini", connectionString); + Assert.Contains("DeploymentId=openai/gpt-4o-mini", connectionString); + Assert.Contains("Key=", connectionString); + } + + [Fact] + public async Task ConnectionStringExpressionWithOrganizationResolvesCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var orgParameter = builder.AddParameter("github-org"); + builder.Configuration["Parameters:github-org"] = "myorg"; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini", orgParameter); + + var connectionString = await github.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Contains("Endpoint=https://models.github.ai/orgs/myorg/inference", connectionString); + Assert.Contains("Model=openai/gpt-4o-mini", connectionString); + Assert.Contains("DeploymentId=openai/gpt-4o-mini", connectionString); + } + + [Fact] + public void ConnectionStringExpressionWithoutOrganization() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + + var connectionString = github.Resource.ConnectionStringExpression.ValueExpression; + + Assert.Contains("Endpoint=https://models.github.ai/inference", connectionString); + Assert.DoesNotContain("/orgs/", connectionString); + Assert.Contains("Model=openai/gpt-4o-mini", connectionString); + Assert.Contains("DeploymentId=openai/gpt-4o-mini", connectionString); + } + + [Fact] + public void GitHubModelResourceConstructorSetsOrganization() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var orgParameter = builder.AddParameter("github-org"); + builder.Configuration["Parameters:github-org"] = "myorg"; + + var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", orgParameter.Resource); + + Assert.Equal("test", resource.Name); + Assert.Equal("openai/gpt-4o-mini", resource.Model); + Assert.Equal(orgParameter.Resource, resource.Organization); + } + + [Fact] + public void GitHubModelResourceConstructorWithNullOrganization() + { + var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", null); + + Assert.Equal("test", resource.Name); + Assert.Equal("openai/gpt-4o-mini", resource.Model); + Assert.Null(resource.Organization); + } + + [Fact] + public void GitHubModelResourceOrganizationCanBeChanged() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var orgParameter = builder.AddParameter("github-org"); + builder.Configuration["Parameters:github-org"] = "myorg"; + + var resource = new GitHubModelResource("test", "openai/gpt-4o-mini", null); + Assert.Null(resource.Organization); + + resource.Organization = orgParameter.Resource; + Assert.Equal(orgParameter.Resource, resource.Organization); + } } From 0dc7f2a29c2977df5559f87583bf79945b742fa8 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 12:37:14 +0200 Subject: [PATCH 12/16] Add health checks --- .../GitHubModelsExtensions.cs | 46 +++++++ .../GitHubModelsHealthCheck.cs | 118 ++++++++++++++++++ src/Aspire.Hosting.GitHub.Models/README.md | 2 +- .../GitHubModelsExtensionTests.cs | 14 +++ 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index 2d6aa3cc201..0f29692d6fb 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -3,6 +3,8 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.GitHub.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting; @@ -28,6 +30,7 @@ public static IResourceBuilder AddGitHubModel(this IDistrib var resource = new GitHubModelResource(name, model, organization?.Resource); return builder.AddResource(resource) + .WithHealthCheck() .WithInitialState(new() { ResourceType = "GitHubModel", @@ -55,4 +58,47 @@ public static IResourceBuilder WithApiKey(this IResourceBui return builder; } + + /// + /// Adds a health check to the GitHub Model resource. + /// + /// The resource builder. + /// The resource builder. + /// + /// + /// This method adds a health check that verifies the GitHub Models endpoint is accessible, + /// the API key is valid, and the specified model is available. The health check will: + /// + /// + /// Return when the endpoint returns HTTP 200 + /// Return with details when the API key is invalid (HTTP 401) + /// Return with error details when the model is unknown (HTTP 404) + /// + /// + internal static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var healthCheckKey = $"{builder.Resource.Name}_github_models_check"; + + // Register the health check + builder.ApplicationBuilder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + healthCheckKey, + sp => + { + var httpClient = sp.GetRequiredService().CreateClient("GitHubModelsHealthCheck"); + + var resource = builder.Resource; + + return new GitHubModelsHealthCheck(httpClient, async () => await resource.ConnectionStringExpression.GetValueAsync(default).ConfigureAwait(false)); + }, + failureStatus: default, + tags: default, + timeout: default)); + + builder.WithHealthCheck(healthCheckKey); + + return builder; + } } diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs new file mode 100644 index 00000000000..c50f7c970e2 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting.GitHub.Models; + +/// +/// A health check for GitHub Models resources. +/// +/// The HttpClient to use. +/// The connection string. +internal sealed class GitHubModelsHealthCheck(HttpClient httpClient, Func> connectionString) : IHealthCheck +{ + /// + /// Checks the health of the GitHub Models endpoint by sending a test request. + /// + /// The health check context. + /// The cancellation token. + /// A task that represents the asynchronous health check operation. + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var builder = new DbConnectionStringBuilder() { ConnectionString = await connectionString().ConfigureAwait(false) }; + + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri($"{builder["Endpoint"]}/chat/completions")); + + // Add required headers + request.Headers.Add("Accept", "application/vnd.github+json"); + request.Headers.Add("Authorization", $"Bearer {builder["Key"]?.ToString()}"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + + // Create test payload with empty messages to minimize API usage + var payload = new + { + model = builder["Model"]?.ToString(), + messages = Array.Empty() + }; + + var jsonPayload = JsonSerializer.Serialize(payload); + request.Content = new StringContent(jsonPayload, System.Text.Encoding.UTF8, "application/json"); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + return response.StatusCode switch + { + HttpStatusCode.Unauthorized => HealthCheckResult.Unhealthy("GitHub Models API key is invalid or has insufficient permissions"), + HttpStatusCode.NotFound or HttpStatusCode.Forbidden or HttpStatusCode.BadRequest => await HandleErrorCode(response, cancellationToken).ConfigureAwait(false), + _ => HealthCheckResult.Unhealthy($"GitHub Models endpoint returned unexpected status code: {response.StatusCode}") + }; + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy($"Failed to check GitHub Models endpoint: {ex.Message}", ex); + } + } + + private static async Task HandleErrorCode(HttpResponseMessage response, CancellationToken cancellationToken) + { + GitHubErrorResponse? errorResponse = null; + + try + { + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + errorResponse = JsonSerializer.Deserialize(content); + + if (errorResponse?.Error?.Code == "unknown_model") + { + var message = !string.IsNullOrEmpty(errorResponse.Error.Message) + ? errorResponse.Error.Message + : "Unknown model"; + return HealthCheckResult.Unhealthy($"GitHub Models: {message}"); + } + else if (errorResponse?.Error?.Code == "empty_array") + { + return HealthCheckResult.Healthy(); + } + else if (errorResponse?.Error?.Code == "no_access") + { + return HealthCheckResult.Unhealthy($"GitHub Models: {errorResponse.Error.Message}"); + } + } + catch + { + } + + return HealthCheckResult.Unhealthy($"GitHub Models returned an unsupported resonse: ({response.StatusCode}) {errorResponse?.Error?.Message}"); + } + + /// + /// Represents the error response from GitHub Models API. + /// + private sealed class GitHubErrorResponse + { + [JsonPropertyName("error")] + public GitHubError? Error { get; set; } + } + + /// + /// Represents an error from GitHub Models API. + /// + private sealed class GitHubError + { + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("details")] + public string? Details { get; set; } + } +} diff --git a/src/Aspire.Hosting.GitHub.Models/README.md b/src/Aspire.Hosting.GitHub.Models/README.md index c1c823039b4..b59df9ccdb8 100644 --- a/src/Aspire.Hosting.GitHub.Models/README.md +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -7,7 +7,7 @@ Provides extension methods and resource definitions for a .NET Aspire AppHost to ### Prerequisites - GitHub account with access to GitHub Models -- GitHub [personal access token](https://docs.github.com/en/github-models/use-github-models/prototyping-with-ai-models#experimenting-with-ai-models-using-the-api) with appropriate permissions +- GitHub [personal access token](https://docs.github.com/en/github-models/use-github-models/prototyping-with-ai-models#experimenting-with-ai-models-using-the-api) with appropriate permissions (`models: read`) ### Install the package diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index 64e9d7d45fe..66dc2654eb2 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -1,6 +1,7 @@ // 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 Xunit; @@ -191,4 +192,17 @@ public void GitHubModelResourceOrganizationCanBeChanged() resource.Organization = orgParameter.Resource; Assert.Equal(orgParameter.Resource, resource.Organization); } + + [Fact] + public void WithHealthCheckAddsHealthCheckAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + + // Verify that the health check annotation is added + var healthCheckAnnotations = github.Resource.Annotations.OfType().ToList(); + Assert.Single(healthCheckAnnotations); + Assert.Equal("github_github_models_check", healthCheckAnnotations[0].Key); + } } From c20d8945ed6dfd9813ff164aadc941d466242832 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 17:11:32 +0200 Subject: [PATCH 13/16] Add manifest files --- .../GitHubModelsEndToEnd.AppHost.csproj | 1 + .../GitHubModelsEndToEnd.AppHost/Program.cs | 3 +- .../aspire-manifest.json | 65 +++++++++++++ .../env.module.bicep | 87 +++++++++++++++++ .../webstory.module.bicep | 93 +++++++++++++++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/aspire-manifest.json create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/env.module.bicep create mode 100644 playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/webstory.module.bicep diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj index 1e0cbb9d757..e5ef791214b 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj @@ -14,6 +14,7 @@ + diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs index 4cc98f2d34b..f38efa24c02 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. var builder = DistributedApplication.CreateBuilder(args); +builder.AddAzureContainerAppEnvironment("env"); var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini"); @@ -11,7 +12,7 @@ builder.AddProject("webstory") .WithExternalHttpEndpoints() - .WithReference(chat); + .WithReference(chat).WaitFor(chat); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/aspire-manifest.json b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..1772bc1e3d8 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/aspire-manifest.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "env": { + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "userPrincipalId": "" + } + }, + "chat": { + "type": "value.v0", + "connectionString": "Endpoint=https://models.github.ai/inference;Key={github-api-key.value};Model=openai/gpt-4o-mini;DeploymentId=openai/gpt-4o-mini" + }, + "github-api-key": { + "type": "parameter.v0", + "value": "{github-api-key.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true + } + } + }, + "webstory": { + "type": "project.v1", + "path": "../GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj", + "deployment": { + "type": "azure.bicep.v0", + "path": "webstory.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "webstory_containerimage": "{webstory.containerImage}", + "webstory_containerport": "{webstory.containerPort}", + "github_api_key_value": "{github-api-key.value}" + } + }, + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "HTTP_PORTS": "{webstory.bindings.http.targetPort}", + "ConnectionStrings__chat": "{chat.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "external": true + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http", + "external": true + } + } + } + } +} \ No newline at end of file diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/env.module.bicep b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/env.module.bicep new file mode 100644 index 00000000000..d37612ff904 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/env.module.bicep @@ -0,0 +1,87 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string + +param tags object = { } + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: tags +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/webstory.module.bicep b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/webstory.module.bicep new file mode 100644 index 00000000000..0d32254a7af --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/webstory.module.bicep @@ -0,0 +1,93 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param webstory_containerimage string + +param webstory_containerport string + +@secure() +param github_api_key_value string + +resource webstory 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'webstory' + location: location + properties: { + configuration: { + secrets: [ + { + name: 'connectionstrings--chat' + value: 'Endpoint=https://models.github.ai/inference;Key=${github_api_key_value};Model=openai/gpt-4o-mini;DeploymentId=openai/gpt-4o-mini' + } + ] + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: int(webstory_containerport) + transport: 'http' + } + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: webstory_containerimage + name: 'webstory' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: webstory_containerport + } + { + name: 'ConnectionStrings__chat' + secretRef: 'connectionstrings--chat' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file From dc50bc6f2c9e72ee4cd1b78be064554e6df6745f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 18:32:35 +0200 Subject: [PATCH 14/16] Make health checks opt-in --- .../GitHubModelsExtensions.cs | 21 +++++++++++++---- .../GitHubModelsHealthCheck.cs | 23 +++++++++++++------ .../GitHubModelsExtensionTests.cs | 2 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs index 0f29692d6fb..6bb0b38faa1 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -30,7 +30,6 @@ public static IResourceBuilder AddGitHubModel(this IDistrib var resource = new GitHubModelResource(name, model, organization?.Resource); return builder.AddResource(resource) - .WithHealthCheck() .WithInitialState(new() { ResourceType = "GitHubModel", @@ -74,12 +73,18 @@ public static IResourceBuilder WithApiKey(this IResourceBui /// Return with details when the API key is invalid (HTTP 401) /// Return with error details when the model is unknown (HTTP 404) /// + /// + /// Because health checks are included in the rate limit of the GitHub Models API, + /// it is recommended to use this health check sparingly, such as when you are having issues understanding the reason + /// the model is not working as expected. Furthermore, the health check will run a single time per application instance. + /// /// - internal static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - var healthCheckKey = $"{builder.Resource.Name}_github_models_check"; + var healthCheckKey = $"{builder.Resource.Name}_check"; + GitHubModelsHealthCheck? healthCheck = null; // Register the health check builder.ApplicationBuilder.Services.AddHealthChecks() @@ -87,11 +92,19 @@ internal static IResourceBuilder WithHealthCheck(this IReso healthCheckKey, sp => { + // Cache the health check instance so we can reuse its result in order to avoid multiple API calls + // that would exhaust the rate limit. + + if (healthCheck is not null) + { + return healthCheck; + } + var httpClient = sp.GetRequiredService().CreateClient("GitHubModelsHealthCheck"); var resource = builder.Resource; - return new GitHubModelsHealthCheck(httpClient, async () => await resource.ConnectionStringExpression.GetValueAsync(default).ConfigureAwait(false)); + return healthCheck = new GitHubModelsHealthCheck(httpClient, async () => await resource.ConnectionStringExpression.GetValueAsync(default).ConfigureAwait(false)); }, failureStatus: default, tags: default, diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs index c50f7c970e2..3cc0d3703a6 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs @@ -16,6 +16,8 @@ namespace Aspire.Hosting.GitHub.Models; /// The connection string. internal sealed class GitHubModelsHealthCheck(HttpClient httpClient, Func> connectionString) : IHealthCheck { + private HealthCheckResult? _result; + /// /// Checks the health of the GitHub Models endpoint by sending a test request. /// @@ -24,6 +26,11 @@ internal sealed class GitHubModelsHealthCheck(HttpClient httpClient, FuncA task that represents the asynchronous health check operation. public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { + if (_result is not null) + { + return _result.Value; + } + try { var builder = new DbConnectionStringBuilder() { ConnectionString = await connectionString().ConfigureAwait(false) }; @@ -34,20 +41,20 @@ public async Task CheckHealthAsync(HealthCheckContext context request.Headers.Add("Accept", "application/vnd.github+json"); request.Headers.Add("Authorization", $"Bearer {builder["Key"]?.ToString()}"); request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); - + // Create test payload with empty messages to minimize API usage var payload = new { model = builder["Model"]?.ToString(), messages = Array.Empty() }; - + var jsonPayload = JsonSerializer.Serialize(payload); request.Content = new StringContent(jsonPayload, System.Text.Encoding.UTF8, "application/json"); - + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - - return response.StatusCode switch + + _result = response.StatusCode switch { HttpStatusCode.Unauthorized => HealthCheckResult.Unhealthy("GitHub Models API key is invalid or has insufficient permissions"), HttpStatusCode.NotFound or HttpStatusCode.Forbidden or HttpStatusCode.BadRequest => await HandleErrorCode(response, cancellationToken).ConfigureAwait(false), @@ -56,14 +63,16 @@ public async Task CheckHealthAsync(HealthCheckContext context } catch (Exception ex) { - return HealthCheckResult.Unhealthy($"Failed to check GitHub Models endpoint: {ex.Message}", ex); + _result = HealthCheckResult.Unhealthy($"Failed to check GitHub Models endpoint: {ex.Message}", ex); } + + return _result.Value; } private static async Task HandleErrorCode(HttpResponseMessage response, CancellationToken cancellationToken) { GitHubErrorResponse? errorResponse = null; - + try { var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index 66dc2654eb2..5b5d408f899 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -198,7 +198,7 @@ public void WithHealthCheckAddsHealthCheckAnnotation() { using var builder = TestDistributedApplicationBuilder.Create(); - var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini"); + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini").WithHealthCheck(); // Verify that the health check annotation is added var healthCheckAnnotations = github.Resource.Annotations.OfType().ToList(); From 60a70f3a33a3d5a25cb5ed4580239a71a603e6e9 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 18:38:19 +0200 Subject: [PATCH 15/16] Feedback --- src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs index 3cc0d3703a6..51015768d00 100644 --- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs @@ -39,7 +39,7 @@ public async Task CheckHealthAsync(HealthCheckContext context // Add required headers request.Headers.Add("Accept", "application/vnd.github+json"); - request.Headers.Add("Authorization", $"Bearer {builder["Key"]?.ToString()}"); + request.Headers.Add("Authorization", $"Bearer {builder["Key"]}"); request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); // Create test payload with empty messages to minimize API usage From 105b05c7a30536614270e40c60f43ba4b1f92349 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 10 Jul 2025 19:20:27 +0200 Subject: [PATCH 16/16] Fix test --- .../GitHubModelsExtensionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs index 5b5d408f899..60e1bbb595b 100644 --- a/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -203,6 +203,6 @@ public void WithHealthCheckAddsHealthCheckAnnotation() // Verify that the health check annotation is added var healthCheckAnnotations = github.Resource.Annotations.OfType().ToList(); Assert.Single(healthCheckAnnotations); - Assert.Equal("github_github_models_check", healthCheckAnnotations[0].Key); + Assert.Equal("github_check", healthCheckAnnotations[0].Key); } }