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/Aspire.slnx b/Aspire.slnx index 75f18c1d001..1227cfc6514 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -49,6 +49,7 @@ + @@ -163,6 +164,10 @@ + + + + @@ -375,6 +380,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..e5ef791214b --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/GitHubModelsEndToEnd.AppHost.csproj @@ -0,0 +1,23 @@ + + + + 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..f38efa24c02 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs @@ -0,0 +1,27 @@ +// 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); +builder.AddAzureContainerAppEnvironment("env"); + +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); + +#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.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/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 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..a23e5daf637 --- /dev/null +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$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" + } + } + } + } 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/GitHubModelResource.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs new file mode 100644 index 00000000000..6ceca9e8ebb --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelResource.cs @@ -0,0 +1,54 @@ +// 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 Model resource. +/// +public class GitHubModelResource : Resource, IResourceWithConnectionString, IResourceWithoutLifetime +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The model name. + /// The organization. + public GitHubModelResource(string name, string model, ParameterResource? organization) : base(name) + { + Model = model; + Organization = organization; + } + + /// + /// Gets or sets the model name, e.g., "openai/gpt-4o-mini". + /// + public string Model { get; set; } + + /// + /// 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); + + /// + /// Gets the connection string expression for the GitHub Models resource. + /// + public ReferenceExpression ConnectionStringExpression => + 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 new file mode 100644 index 00000000000..cabfd188121 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs @@ -0,0 +1,117 @@ +// 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; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding GitHub Models resources to the application model. +/// +public static class GitHubModelsExtensions +{ + /// + /// 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. + /// 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, IResourceBuilder? organization = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(model); + + var resource = new GitHubModelResource(name, model, organization?.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") + ] + }); + } + + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(apiKey); + + builder.Resource.Key = apiKey.Resource; + + 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) + /// + /// + /// 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. + /// + /// + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var healthCheckKey = $"{builder.Resource.Name}_check"; + GitHubModelsHealthCheck? healthCheck = null; + + // Register the health check + builder.ApplicationBuilder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + 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 healthCheck = 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..77f29d50b52 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsHealthCheck.cs @@ -0,0 +1,127 @@ +// 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 +{ + private HealthCheckResult? _result; + + /// + /// 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) + { + if (_result is not null) + { + return _result.Value; + } + + 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"]}"); + 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); + + _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), + _ => HealthCheckResult.Unhealthy($"GitHub Models endpoint returned unexpected status code: {response.StatusCode}") + }; + } + catch (Exception 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); + 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 response: ({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 new file mode 100644 index 00000000000..f552b504e00 --- /dev/null +++ b/src/Aspire.Hosting.GitHub.Models/README.md @@ -0,0 +1,96 @@ +# 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](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 + +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 Model 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", "openai/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 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); +``` + +Then in user secrets: + +```json +{ + "Parameters": + { + "github-api-key": "YOUR_GITHUB_TOKEN_HERE" + } +} +``` + +Or directly as a string (not recommended for production): + +```csharp +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: + +- `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. + +## 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/src/Shared/GitHub_256x.png b/src/Shared/GitHub_256x.png new file mode 100644 index 00000000000..3e560d441f3 Binary files /dev/null and b/src/Shared/GitHub_256x.png differ 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..60e1bbb595b --- /dev/null +++ b/tests/Aspire.Hosting.GitHub.Models.Tests/GitHubModelsExtensionTests.cs @@ -0,0 +1,208 @@ +// 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", "openai/gpt-4o-mini"); + + Assert.Equal("github", github.Resource.Name); + Assert.Equal("openai/gpt-4o-mini", github.Resource.Model); + } + + [Fact] + public void AddGitHubModelUsesCorrectEndpoint() + { + 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); + } + + [Fact] + public void ConnectionStringExpressionIsCorrectlyFormatted() + { + 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.Contains("Model=openai/gpt-4o-mini", connectionString); + Assert.Contains("DeploymentId=openai/gpt-4o-mini", connectionString); + Assert.Contains("Key=", connectionString); + } + + [Fact] + public async Task WithApiKeySetFromParameter() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + const string apiKey = "randomkey"; + + var apiKeyParameter = builder.AddParameter("github-api-key", secret: true); + builder.Configuration["Parameters:github-api-key"] = apiKey; + + var github = builder.AddGitHubModel("github", "openai/gpt-4o-mini") + .WithApiKey(apiKeyParameter); + + var connectionString = await github.Resource.ConnectionStringExpression.GetValueAsync(default); + + 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); + } + + [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); + } + + [Fact] + public void WithHealthCheckAddsHealthCheckAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + 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(); + Assert.Single(healthCheckAnnotations); + Assert.Equal("github_check", healthCheckAnnotations[0].Key); + } +}