File: FoundryExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Foundry\Aspire.Hosting.Foundry.csproj (Aspire.Hosting.Foundry)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Foundry;
using Aspire.Hosting.Eventing;
using Azure.Provisioning;
using Azure.Provisioning.CognitiveServices;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Resources;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Threading.Channels;
using static Azure.Provisioning.Expressions.BicepFunction;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for adding the Microsoft Foundry resources to the application model.
/// </summary>
public static class FoundryExtensions
{
    private const string DefaultCapabilityHostName = "foundry-caphost";
    internal const string LocalProjectsNotSupportedMessage = "Microsoft Foundry projects are not supported when the parent Foundry resource is configured with RunAsFoundryLocal().";
 
    /// <summary>
    /// Adds a Microsoft Foundry resource to the application model.
    /// </summary>
    /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
    /// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    public static IResourceBuilder<FoundryResource> AddFoundry(this IDistributedApplicationBuilder builder, [ResourceName] string name)
    {
        builder.AddAzureProvisioning();
 
        var resource = new FoundryResource(name, ConfigureInfrastructure);
        return builder.AddResource(resource)
            .WithIconName("AgentsAdd")
            .WithDefaultRoleAssignments(CognitiveServicesBuiltInRole.GetBuiltInRoleName,
                CognitiveServicesBuiltInRole.CognitiveServicesUser, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser);
    }
 
    /// <summary>
    /// Adds and returns a Microsoft Foundry Deployment resource (e.g. an AI model) to the application model.
    /// </summary>
    /// <param name="builder">The Microsoft Foundry resource builder.</param>
    /// <param name="name">The name of the Microsoft Foundry Deployment resource.</param>
    /// <param name="modelName">The name of the model to deploy.</param>
    /// <param name="modelVersion">The version of the model to deploy.</param>
    /// <param name="format">The format of the model to deploy.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    [AspireExportIgnore(Reason = "Polyglot AppHosts use the internal addDeployment dispatcher export.")]
    public static IResourceBuilder<FoundryDeploymentResource> AddDeployment(this IResourceBuilder<FoundryResource> builder, [ResourceName] string name, string modelName, string modelVersion, string format)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(name);
        ArgumentException.ThrowIfNullOrEmpty(modelName);
        ArgumentException.ThrowIfNullOrEmpty(modelVersion);
        ArgumentException.ThrowIfNullOrEmpty(format);
 
        var deployment = new FoundryDeploymentResource(name, modelName, modelVersion, format, builder.Resource);
 
        builder.ApplicationBuilder.AddResource(deployment);
 
        builder.Resource.AddDeployment(deployment);
 
        var deploymentBuilder = builder.ApplicationBuilder
            .CreateResourceBuilder(deployment);
 
        if (builder.Resource.IsEmulator)
        {
            deploymentBuilder.AsLocalDeployment(deployment);
        }
 
        return deploymentBuilder.WithIconName("BoxMultiple");
    }
 
    /// <summary>
    /// Adds a Microsoft Foundry deployment resource to a Microsoft Foundry resource.
    /// </summary>
    [AspireExport("addDeployment")]
    internal static IResourceBuilder<FoundryDeploymentResource> AddDeploymentForPolyglot(
        this IResourceBuilder<FoundryResource> builder,
        [ResourceName] string name,
        [AspireUnion(typeof(FoundryModel), typeof(string))] object model,
        string? modelVersion = null,
        string? format = null)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(model);
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        return model switch
        {
            FoundryModel foundryModel when modelVersion is null && format is null => builder.AddDeployment(name, foundryModel),
            FoundryModel => throw new ArgumentException("Model version and format must be omitted when using a FoundryModel.", nameof(modelVersion)),
            string modelName when modelVersion is not null && format is not null => builder.AddDeployment(name, modelName, modelVersion, format),
            string => throw new ArgumentException("Model version and format are required when the model is provided as a string.", nameof(modelVersion)),
            _ => throw new ArgumentException("Model must be a FoundryModel or a string model name.", nameof(model))
        };
    }
 
    /// <summary>
    /// Adds and returns a Microsoft Foundry Deployment resource to the application model using a <see cref="FoundryModel"/>.
    /// </summary>
    /// <param name="builder">The Microsoft Foundry resource builder.</param>
    /// <param name="name">The name of the Microsoft Foundry Deployment resource.</param>
    /// <param name="model">The model descriptor, using the <see cref="FoundryModel"/> class like so: <code lang="csharp">aiFoundry.AddDeployment(name: "chat", model: FoundryModel.OpenAI.Gpt5Mini)</code></param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    /// <remarks>
    /// <example>
    /// Create a deployment for the OpenAI GTP-5-mini model:
    /// <code lang="csharp">
    /// var builder = DistributedApplication.CreateBuilder(args);
    ///
    /// var aiFoundry = builder.AddFoundry("aiFoundry");
    /// var gpt5mini = aiFoundry.AddDeployment("chat", FoundryModel.OpenAI.Gpt5Mini);
    /// </code>
    /// </example>
    /// </remarks>
    [AspireExportIgnore(Reason = "Polyglot AppHosts use the internal addDeployment dispatcher export.")]
    public static IResourceBuilder<FoundryDeploymentResource> AddDeployment(this IResourceBuilder<FoundryResource> builder, [ResourceName] string name, FoundryModel model)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(model);
        ArgumentException.ThrowIfNullOrEmpty(name);
        ArgumentException.ThrowIfNullOrEmpty(model.Name);
        ArgumentException.ThrowIfNullOrEmpty(model.Version);
        ArgumentException.ThrowIfNullOrEmpty(model.Format);
 
        return builder.AddDeployment(name, model.Name, model.Version, model.Format);
    }
 
    /// <summary>
    /// Allows setting the properties of a Microsoft Foundry Deployment resource.
    /// </summary>
    /// <param name="builder">The Microsoft Foundry Deployment resource builder.</param>
    /// <param name="configure">A method that can be used for customizing the <see cref="FoundryDeploymentResource"/>.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport("withFoundryDeploymentProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)]
    public static IResourceBuilder<FoundryDeploymentResource> WithProperties(this IResourceBuilder<FoundryDeploymentResource> builder, Action<FoundryDeploymentResource> configure)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(configure);
 
        configure(builder.Resource);
 
        return builder;
    }
 
    /// <summary>
    /// Adds a Foundry Local resource to the distributed application builder.
    /// </summary>
    /// <param name="builder">The distributed application builder.</param>
    /// <returns>A resource builder for the Foundry Local resource.</returns>
    [AspireExport]
    public static IResourceBuilder<FoundryResource> RunAsFoundryLocal(this IResourceBuilder<FoundryResource> builder)
    {
        ArgumentNullException.ThrowIfNull(builder, nameof(builder));
 
        if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
        {
            return builder;
        }
 
        var resource = builder.Resource;
        ThrowIfProjectsConfiguredForLocal(builder, resource);
        resource.Annotations.Add(new EmulatorResourceAnnotation());
 
        builder.WithInitializer();
        builder.OnResourceStopped(static (_, _, ct) => FoundryLocalService.StopAsync(ct));
 
        foreach (var deployment in resource.Deployments)
        {
            var deploymentBuilder = builder.ApplicationBuilder
                .CreateResourceBuilder(deployment);
 
            deploymentBuilder.AsLocalDeployment(deployment);
        }
 
        var healthCheckKey = $"{resource.Name}_check";
        builder.ApplicationBuilder.Services.AddHealthChecks()
                .Add(new HealthCheckRegistration(
                    healthCheckKey,
                    sp => new FoundryLocalHealthCheck(),
                    failureStatus: default,
                    tags: default,
                    timeout: default
                    ));
        builder.ApplicationBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, FoundryLocalLifecycleService>());
 
        builder.WithHealthCheck(healthCheckKey);
 
        return builder;
    }
 
    internal static void ThrowIfProjectsConfiguredForLocal(IResourceBuilder<FoundryResource> builder, FoundryResource resource)
    {
        if (builder.ApplicationBuilder.Resources
            .OfType<AzureCognitiveServicesProjectResource>()
            .Any(project => ReferenceEquals(project.Parent, resource)))
        {
            throw new InvalidOperationException(LocalProjectsNotSupportedMessage);
        }
    }
 
    /// <summary>
    /// Assigns the specified roles to the given resource, granting it the necessary permissions
    /// on the target Microsoft Foundry resource. This replaces the default role assignments for the resource.
    /// </summary>
    /// <param name="builder">The resource to which the specified roles will be assigned.</param>
    /// <param name="target">The target Microsoft Foundry resource.</param>
    /// <param name="roles">The built-in Cognitive Services roles to be assigned.</param>
    /// <returns>The updated <see cref="IResourceBuilder{T}"/> with the applied role assignments.</returns>
    /// <remarks>
    /// <example>
    /// Assigns the CognitiveServicesOpenAIContributor role to the 'Projects.Api' project.
    /// <code lang="csharp">
    /// var builder = DistributedApplication.CreateBuilder(args);
    ///
    /// var aiFoundry = builder.AddFoundry("aiFoundry");
    ///
    /// var api = builder.AddProject&lt;Projects.Api&gt;("api")
    ///   .WithRoleAssignments(aiFoundry, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIContributor)
    ///   .WithReference(aiFoundry);
    /// </code>
    /// </example>
    /// </remarks>
    [AspireExportIgnore(Reason = "CognitiveServicesBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the FoundryRole-based overload instead.")]
    public static IResourceBuilder<T> WithRoleAssignments<T>(
        this IResourceBuilder<T> builder,
        IResourceBuilder<FoundryResource> target,
        params CognitiveServicesBuiltInRole[] roles)
        where T : IResource
    {
        return builder.WithRoleAssignments(target, CognitiveServicesBuiltInRole.GetBuiltInRoleName, roles);
    }
 
    /// <summary>
    /// Assigns the specified roles to the given resource, granting it the necessary permissions
    /// on the target Microsoft Foundry resource. This replaces the default role assignments for the resource.
    /// </summary>
    /// <param name="builder">The resource to which the specified roles will be assigned.</param>
    /// <param name="target">The target Microsoft Foundry resource.</param>
    /// <param name="roles">The Microsoft Foundry roles to be assigned (for example, <see cref="FoundryRole.CognitiveServicesOpenAIUser"/>).</param>
    /// <returns>The updated <see cref="IResourceBuilder{T}"/> with the applied role assignments.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <exception cref="ArgumentException">Thrown when a role value is not a valid <see cref="FoundryRole"/> value.</exception>
    [AspireExport("withFoundryRoleAssignments")]
    internal static IResourceBuilder<T> WithRoleAssignments<T>(
        this IResourceBuilder<T> builder,
        IResourceBuilder<FoundryResource> target,
        params FoundryRole[] roles)
        where T : IResource
    {
        if (roles is null || roles.Length == 0)
        {
            return builder.WithRoleAssignments(target, Array.Empty<CognitiveServicesBuiltInRole>());
        }
 
        var builtInRoles = new CognitiveServicesBuiltInRole[roles.Length];
        for (var i = 0; i < roles.Length; i++)
        {
            builtInRoles[i] = roles[i] switch
            {
                FoundryRole.CognitiveServicesOpenAIContributor => CognitiveServicesBuiltInRole.CognitiveServicesOpenAIContributor,
                FoundryRole.CognitiveServicesOpenAIUser => CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser,
                FoundryRole.CognitiveServicesUser => CognitiveServicesBuiltInRole.CognitiveServicesUser,
                _ => throw new ArgumentException($"'{roles[i]}' is not a valid {nameof(FoundryRole)} value.", nameof(roles))
            };
        }
 
        return builder.WithRoleAssignments(target, builtInRoles);
    }
 
    private static IResourceBuilder<FoundryResource> WithInitializer(this IResourceBuilder<FoundryResource> builder)
    {
        return builder.OnInitializeResource((resource, @event, ct)
            => Task.Run(async () =>
            {
                var rns = @event.Services.GetRequiredService<ResourceNotificationService>();
                var logger = @event.Services.GetRequiredService<ResourceLoggerService>().GetLogger(resource);
 
                resource.ApiKey = FoundryLocalService.ApiKey;
 
                await rns.PublishUpdateAsync(resource, state => state with
                {
                    State = new ResourceStateSnapshot(KnownResourceStates.Starting, KnownResourceStateStyles.Info)
                }).ConfigureAwait(false);
 
                try
                {
                    await FoundryLocalService.StartAsync(logger, ct).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    logger.LogInformation("Foundry Local could not be started. Ensure it's installed correctly: https://learn.microsoft.com/azure/ai-foundry/foundry-local/get-started (Error: {Error}).", e.Message);
                }
 
                if (FoundryLocalService.IsServiceRunning)
                {
                    resource.EmulatorServiceUri = FoundryLocalService.Endpoint;
 
                    await rns.PublishUpdateAsync(resource, state => state with
                    {
                        State = KnownResourceStates.Running,
                        Properties = [.. state.Properties, new(CustomResourceKnownProperties.Source, "Foundry Local")]
                    }).ConfigureAwait(false);
                }
                else
                {
                    await rns.PublishUpdateAsync(resource, state => state with
                    {
                        State = KnownResourceStates.FailedToStart,
                        Properties = [.. state.Properties, new(CustomResourceKnownProperties.Source, "Foundry Local")]
                    }).ConfigureAwait(false);
                }
 
            }, ct));
    }
 
    /// <summary>
    /// Configure a deployment for use with Foundry Local
    /// </summary>
    internal static IResourceBuilder<FoundryDeploymentResource> AsLocalDeployment(this IResourceBuilder<FoundryDeploymentResource> builder, FoundryDeploymentResource deployment)
    {
        ArgumentNullException.ThrowIfNull(deployment, nameof(deployment));
 
        var foundryResource = builder.Resource.Parent;
        builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(foundryResource, (@event, ct) =>
        {
            var rns = @event.Services.GetRequiredService<ResourceNotificationService>();
            var loggerService = @event.Services.GetRequiredService<ResourceLoggerService>();
            var logger = loggerService.GetLogger(deployment);
            var eventing = @event.Services.GetRequiredService<IDistributedApplicationEventing>();
 
            var model = deployment.ModelName;
 
            _ = Task.Run(async () =>
            {
                await rns.PublishUpdateAsync(deployment, state => state with
                {
                    State = new ResourceStateSnapshot($"Downloading model {model}", KnownResourceStateStyles.Info),
                    Properties = [.. state.Properties, new(CustomResourceKnownProperties.Source, model)]
                }).ConfigureAwait(false);
 
                var progressChannel = Channel.CreateUnbounded<float>();
                var downloadTask = DownloadModelAsync();
 
                await foreach (var progress in progressChannel.Reader.ReadAllAsync(ct).ConfigureAwait(false))
                {
                    logger.LogInformation("Downloading model {Model}: {Progress:F2}%", model, progress);
                    await rns.PublishUpdateAsync(deployment, state => state with
                    {
                        State = new ResourceStateSnapshot($"Downloading model {model}: {progress:F2}%", KnownResourceStateStyles.Info)
                    }).ConfigureAwait(false);
                }
 
                try
                {
                    deployment.ModelId = await downloadTask.ConfigureAwait(false);
                    logger.LogInformation("Model {Model} downloaded successfully ({ModelId}).", model, deployment.ModelId);
 
                    // Re-publish the connection string since the model id is now known.
                    var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(deployment, @event.Services);
                    await eventing.PublishAsync(connectionStringAvailableEvent, ct).ConfigureAwait(false);
 
                    await rns.PublishUpdateAsync(deployment, state => state with
                    {
                        Properties = [.. state.Properties, new(CustomResourceKnownProperties.Source, $"{model} ({deployment.ModelId})")]
                    }).ConfigureAwait(false);
 
                    await rns.PublishUpdateAsync(deployment, state => state with
                    {
                        State = new ResourceStateSnapshot("Loading model", KnownResourceStateStyles.Info)
                    }).ConfigureAwait(false);
 
                    await FoundryLocalService.LoadModelAsync(deployment.ModelId, ct).ConfigureAwait(false);
 
                    await rns.PublishUpdateAsync(deployment, state => state with
                    {
                        State = KnownResourceStates.Running
                    }).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    logger.LogInformation("Failed to start {Model}. Error: {Error}", model, e.Message);
 
                    await rns.PublishUpdateAsync(deployment, state => state with
                    {
                        State = KnownResourceStates.FailedToStart
                    }).ConfigureAwait(false);
                }
 
                async Task<string> DownloadModelAsync()
                {
                    try
                    {
                        return await FoundryLocalService.DownloadModelAsync(model, progress => progressChannel.Writer.TryWrite(progress), ct).ConfigureAwait(false);
                    }
                    finally
                    {
                        progressChannel.Writer.TryComplete();
                    }
                }
            }, ct);
 
            return Task.CompletedTask;
        });
 
        var healthCheckKey = $"{deployment.Name}_check";
 
        builder.ApplicationBuilder.Services.AddHealthChecks()
                .Add(new HealthCheckRegistration(
                    healthCheckKey,
                    sp => new LocalModelHealthCheck(modelId: deployment.ModelId),
                    failureStatus: default,
                    tags: default,
                    timeout: default
                    ));
 
        builder.WithHealthCheck(healthCheckKey);
 
        return builder;
    }
 
    private static void ConfigureInfrastructure(AzureResourceInfrastructure infrastructure)
    {
        var azureResource = (FoundryResource)infrastructure.AspireResource;
 
        // Check if this Foundry resource has a private endpoint (via annotation)
        var hasPrivateEndpoint = azureResource.HasAnnotationOfType<PrivateEndpointTargetAnnotation>();
 
        var cogServicesAccount = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure,
                (identifier, name) =>
                {
                    var resource = CognitiveServicesAccount.FromExisting(identifier);
                    resource.Name = name;
                    return resource;
                },
                (infrastructure) =>
                {
                    // Cognitive Services account names are limited to 64 characters; reserve room for the unique suffix.
                    var accountNamePrefix = infrastructure.AspireResource.Name[..Math.Min(infrastructure.AspireResource.Name.Length, 50)];
                    var accountName = ToLower(Interpolate($"{accountNamePrefix}-{GetUniqueString(GetResourceGroup().Id)}"));
 
                    return new CognitiveServicesAccount(infrastructure.AspireResource.GetBicepIdentifier())
                    {
                        Name = accountName,
                        Kind = "AIServices",
                        Sku = new CognitiveServicesSku()
                        {
                            Name = "S0"
                        },
                        Properties = new CognitiveServicesAccountProperties()
                        {
                            CustomSubDomainName = accountName,
                            PublicNetworkAccess = hasPrivateEndpoint
                                ? ServiceAccountPublicNetworkAccess.Disabled
                                : ServiceAccountPublicNetworkAccess.Enabled,
                            DisableLocalAuth = true,
                            AllowProjectManagement = true
                        },
                        Identity = new ManagedServiceIdentity()
                        {
                            ManagedServiceIdentityType = ManagedServiceIdentityType.SystemAssigned
                        },
                        Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
                    };
                });
 
        infrastructure.Add(new ProvisioningOutput("aiFoundryApiEndpoint", typeof(string))
        {
            Value = (BicepValue<string>)new IndexExpression(
                (BicepExpression)cogServicesAccount.Properties.Endpoints!,
                "AI Foundry API")
        });
 
        infrastructure.Add(new ProvisioningOutput("endpoint", typeof(string))
        {
            Value = cogServicesAccount.Properties.Endpoint.ToBicepExpression()
        });
 
        infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = cogServicesAccount.Name.ToBicepExpression() });
 
        infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = cogServicesAccount.Id.ToBicepExpression() });
 
        var resource = (FoundryResource)infrastructure.AspireResource;
 
        if (resource.CapabilityHost != null)
        {
            // Use the specified capability host
            resource.CapabilityHost.Parent = cogServicesAccount;
            infrastructure.Add(resource.CapabilityHost);
        }
        else
        {
            // Provision a default capability host for hosted agents
            var capHost = new CognitiveServicesCapabilityHost(Infrastructure.NormalizeBicepIdentifier($"{resource.Name}-caphost"), "2025-10-01-preview")
            {
                Name = DefaultCapabilityHostName,
                Parent = cogServicesAccount,
                // IMPORTANT: this is required to enable hosted agents deployment
                // if no BYO Net is provided
                Properties = new PublicHostingCognitiveServicesCapabilityHostProperties()
                {
                    CapabilityHostKind = CapabilityHostKind.Agents
                }
            };
            infrastructure.Add(capHost);
            resource.CapabilityHost = capHost;
        }
 
        CognitiveServicesAccountDeployment? dependency = null;
        foreach (var deployment in resource.Deployments)
        {
            var cdkDeployment = new CognitiveServicesAccountDeployment(Infrastructure.NormalizeBicepIdentifier(deployment.Name))
            {
                Name = deployment.DeploymentName,
                Parent = cogServicesAccount,
                Properties = new CognitiveServicesAccountDeploymentProperties()
                {
                    Model = new CognitiveServicesAccountDeploymentModel()
                    {
                        Name = deployment.ModelName,
                        Version = deployment.ModelVersion,
                        Format = deployment.Format
                    }
                },
                Sku = new CognitiveServicesSku()
                {
                    Name = deployment.SkuName,
                    Capacity = deployment.SkuCapacity
                }
            };
            infrastructure.Add(cdkDeployment);
 
            // Subsequent deployments need an explicit dependency on the previous one
            // to ensure they are not created in parallel. This is equivalent to @batchSize(1)
            // which can't be defined with the CDK
 
            if (dependency != null)
            {
                cdkDeployment.DependsOn.Add(dependency);
            }
 
            dependency = cdkDeployment;
        }
    }
 
}