Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 System.Net;
using Aspire;
using Aspire.Azure.AI.Inference;
using Aspire.Azure.Common;
Expand Down Expand Up @@ -186,10 +187,10 @@ protected override void BindSettingsToConfiguration(ChatCompletionsClientSetting
=> configuration.Bind(settings);

protected override IHealthCheck CreateHealthCheck(ChatCompletionsClient client, ChatCompletionsClientSettings settings)
=> throw new NotImplementedException();
=> new AzureAIInferenceChatCompletionsHealthCheck(client);

protected override bool GetHealthCheckEnabled(ChatCompletionsClientSettings settings)
=> false;
=> !settings.DisableHealthChecks && SupportsModelInfoHealthCheck(settings.Endpoint);

protected override bool GetMetricsEnabled(ChatCompletionsClientSettings settings)
=> !settings.DisableMetrics;
Expand Down Expand Up @@ -439,10 +440,10 @@ protected override void BindSettingsToConfiguration(ChatCompletionsClientSetting
=> configuration.Bind(settings);

protected override IHealthCheck CreateHealthCheck(EmbeddingsClient client, ChatCompletionsClientSettings settings)
=> throw new NotImplementedException();
=> new AzureAIInferenceEmbeddingsHealthCheck(client);

protected override bool GetHealthCheckEnabled(ChatCompletionsClientSettings settings)
=> false;
=> !settings.DisableHealthChecks && SupportsModelInfoHealthCheck(settings.Endpoint);

protected override bool GetMetricsEnabled(ChatCompletionsClientSettings settings)
=> !settings.DisableMetrics;
Expand All @@ -454,6 +455,42 @@ protected override bool GetTracingEnabled(ChatCompletionsClientSettings settings
=> !settings.DisableTracing;
}

private static bool SupportsModelInfoHealthCheck(Uri? endpoint)
{
if (endpoint is null)
{
return false;
}

// The SDK's /info operation is unsupported by Azure OpenAI endpoints and local dev servers
// (Foundry Local, Ollama, etc.) that serve OpenAI-compatible routes but not /info.
// See https://learn.microsoft.com/dotnet/api/azure.ai.inference.chatcompletionsclient.getmodelinfoasync.
// Keep these domains aligned with Azure OpenAI endpoint additions until the SDK exposes a capability
// signal that can replace domain inference. DisableHealthChecks is the escape hatch for unrecognized endpoints.
var host = endpoint.Host;

// Exclude loopback endpoints (Foundry Local emits http://127.0.0.1:<port>/).
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| (IPAddress.TryParse(host, out var ip) && IPAddress.IsLoopback(ip)))
{
return false;
}

return !IsHostOrSubdomain(host, "openai.azure.com")
&& !IsHostOrSubdomain(host, "openai.azure.us")
&& !IsHostOrSubdomain(host, "openai.azure.cn")
&& !IsHostOrSubdomain(host, "openai.azure.de")
&& !(IsHostOrSubdomain(host, "services.ai.azure.com") && IsPathOrSubpath(endpoint.AbsolutePath, "/openai"));
Comment thread
sebastienros marked this conversation as resolved.
Comment thread
sebastienros marked this conversation as resolved.

static bool IsHostOrSubdomain(string host, string domain)
=> host.Equals(domain, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith($".{domain}", StringComparison.OrdinalIgnoreCase);

static bool IsPathOrSubpath(string path, string expectedPath)
=> path.Equals(expectedPath, StringComparison.OrdinalIgnoreCase)
|| path.StartsWith($"{expectedPath}/", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Creates a <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> from the <see cref="EmbeddingsClient"/> registered in the service collection.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Azure.AI.Inference;
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Aspire.Azure.AI.Inference;

internal sealed class AzureAIInferenceChatCompletionsHealthCheck : IHealthCheck
{
private readonly ChatCompletionsClient _client;

public AzureAIInferenceChatCompletionsHealthCheck(ChatCompletionsClient client)
=> _client = client;

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
await _client.GetModelInfoAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return new HealthCheckResult(context.Registration.FailureStatus, exception: ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Azure.AI.Inference;
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Aspire.Azure.AI.Inference;

internal sealed class AzureAIInferenceEmbeddingsHealthCheck : IHealthCheck
{
private readonly EmbeddingsClient _client;

public AzureAIInferenceEmbeddingsHealthCheck(EmbeddingsClient client)
=> _client = client;

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
await _client.GetModelInfoAsync(cancellationToken).ConfigureAwait(false);
Comment thread
sebastienros marked this conversation as resolved.
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return new HealthCheckResult(context.Registration.FailureStatus, exception: ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ public sealed class ChatCompletionsClientSettings : IConnectionStringSettings
/// </summary>
public string? Key { get; set; }

/// <summary>
/// Gets or sets a boolean value that indicates whether the health check is disabled or not.
/// </summary>
/// <value>
/// The default value is <see langword="false"/>.
/// </value>
public bool DisableHealthChecks { get; set; }

/// <summary>
/// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@
"type": "string",
"description": "Gets or sets the name of the AI model deployment to use for chat completions."
},
"DisableHealthChecks": {
"type": "boolean",
"description": "Gets or sets a boolean value that indicates whether the health check is disabled or not.",
"default": false
},
"DisableMetrics": {
"type": "boolean",
"description": "Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not.",
Expand Down
23 changes: 16 additions & 7 deletions src/Components/Aspire.Azure.AI.Inference/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Aspire.Azure.AI.Inference library

Registers [ChatCompletionsClient](https://learn.microsoft.com/dotnet/api/azure.ai.inference.chatcompletionsclient) as a singleton in the DI container for connecting to Microsoft Foundry and GitHub Models. Enables corresponding metrics, logging and telemetry.
Registers [ChatCompletionsClient](https://learn.microsoft.com/dotnet/api/azure.ai.inference.chatcompletionsclient) and [EmbeddingsClient](https://learn.microsoft.com/dotnet/api/azure.ai.inference.embeddingsclient) as singletons in the DI container for connecting to Microsoft Foundry. Enables corresponding health checks, metrics, logging and telemetry.

## Getting started

Expand All @@ -25,6 +25,12 @@ In the _Program.cs_ file of your project, call the `AddAzureChatCompletionsClien
builder.AddAzureChatCompletionsClient("connectionName");
```

To register an `EmbeddingsClient`, call `AddAzureEmbeddingsClient` with the connection name:

```csharp
builder.AddAzureEmbeddingsClient("connectionName");
```

You can then retrieve the `ChatCompletionsClient` instance using dependency injection. For example, to retrieve the client from a Web API controller:

```csharp
Expand All @@ -44,17 +50,17 @@ The Aspire Azure AI Inference library provides multiple options to configure the

### Use a connection string

A connection can be constructed from the **Keys, Deployment ID and Endpoint** tab with the format `Endpoint={endpoint};Key={key};Deployment={deploymentName}`. You can provide the name of the connection string when calling `builder.AddChatCompletionsClient()`:
A connection can be constructed from the **Keys, Deployment ID and Endpoint** tab with the format `Endpoint={endpoint};Key={key};Deployment={deploymentName}`. You can provide the name of the connection string when calling `builder.AddAzureChatCompletionsClient()`:

```csharp
builder.AddChatCompletionsClient("connectionName");
builder.AddAzureChatCompletionsClient("connectionName");
```

And then the connection string will be retrieved from the `ConnectionStrings` configuration section. Two connection formats are supported:

#### Microsoft Foundry Endpoint

The recommended approach is to use an Endpoint, which works with the `ChatCompletionsClientSettings.Credential` property to establish a connection. If no credential is configured, a [default TokenCredential is created based on the current environment](https://aka.ms/aspire/default-azure-credential).
The recommended approach is to use an Endpoint, which works with the `ChatCompletionsClientSettings.TokenCredential` property to establish a connection. If no credential is configured, a [default TokenCredential is created based on the current environment](https://aka.ms/aspire/default-azure-credential).

```json
{
Expand All @@ -81,7 +87,7 @@ Alternatively, a custom connection string can be used.
The library supports multiple formats for specifying the deployment:

- **`Deployment={deploymentName}`** - Preferred format for Microsoft Foundry deployments
- **`Model={modelName}`** - Format used by GitHub Models
- **`Model={modelName}`** - Legacy model-name format

Only one of these keys should be present in a connection string. If multiple are provided, an `ArgumentException` will be thrown.

Expand All @@ -95,6 +101,7 @@ The Aspire Azure AI Inference library supports [Microsoft.Extensions.Configurati
"Azure": {
"AI": {
"Inference": {
"DisableHealthChecks": false,
"DisableTracing": false,
"ClientOptions": {
"UserAgentApplicationId": "myapp"
Expand All @@ -108,12 +115,14 @@ The Aspire Azure AI Inference library supports [Microsoft.Extensions.Configurati

### Use inline delegates

You can also pass the `Action<ChatCompletionsClientSettings> configureSettings` delegate to set up some or all the options inline, for example to disable tracing from code:
You can also pass the `Action<ChatCompletionsClientSettings> configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code:

```csharp
builder.AddAzureChatCompletionsClient("connectionName", settings => settings.DisableTracing = true);
builder.AddAzureChatCompletionsClient("connectionName", settings => settings.DisableHealthChecks = true);
```

Health checks are enabled by default for Microsoft Foundry model inference endpoints and use the service's `/info` operation. The Azure AI Inference SDK does not support this operation for Azure OpenAI or Foundry Local endpoints, so health checks are not registered when an Azure OpenAI or loopback endpoint is detected. Set `DisableHealthChecks` to `true` for any other endpoint that does not support `/info`.

You can also setup the [AzureAIInferenceClientOptions](https://learn.microsoft.com/dotnet/api/azure.ai.inference.AzureAIInferenceClientOptions) using the optional `Action<IAzureClientBuilder<ChatCompletionsClient, AzureAIInferenceClientOptions>> configureClientBuilder` parameter of the `AddAzureChatCompletionsClient` method. For example, to set the client ID for this client:

```csharp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ protected override void BindSettingsToConfiguration(AzureOpenAISettings settings

protected override IHealthCheck CreateHealthCheck(AzureOpenAIClient client, AzureOpenAISettings settings)
{
throw new NotImplementedException();
// Azure OpenAI does not expose a lightweight read-only health check endpoint.
// GetOpenAIModelClient() explicitly throws NotSupportedException in AzureOpenAIClient,
// and all other sub-clients require a deployment name and initiate inference operations.
// Health checks remain disabled until a suitable API is available in the Azure.AI.OpenAI SDK.
throw new NotSupportedException("Health checks are not supported for AzureOpenAIClient.");
}

protected override bool GetHealthCheckEnabled(AzureOpenAISettings settings)
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Aspire_Components_Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ These integrations should follow the [Aspire Client Integration Requirements](#a
| Microsoft.Extensions.Configuration.AzureAppConfiguration | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | |
| MongoDB.Driver | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| MongoDB.EntityFrameworkCore | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Azure.AI.Inference | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| Azure.AI.Inference | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Azure.AI.OpenAI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Azure.Data.Tables | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Azure.Messaging.EventHubs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Aspire.Azure.Security.KeyVault.Tests\MockTransport.cs" />
<None Include="$(RepoRoot)src\Components\Aspire.Azure.AI.Inference\ConfigurationSchema.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Azure.AI.Inference;
using Azure.Core;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Xunit;

Expand Down Expand Up @@ -139,6 +142,73 @@ public void AddChatClientUsesCustomDeploymentId(bool useKeyed)
Assert.Equal("other", metadata?.DefaultModelId);
}

[Theory]
[InlineData("https://account.services.ai.azure.com/models", true)]
[InlineData("https://account.openai.azure.com/openai/deployments/model", false)]
[InlineData("https://account.openai.azure.us/openai/deployments/model", false)]
[InlineData("https://account.openai.azure.cn/openai/deployments/model", false)]
[InlineData("https://account.openai.azure.de/openai/deployments/model", false)]
[InlineData("https://account.services.ai.azure.com/OpenAI/v1", false)]
[InlineData("http://127.0.0.1:50920/", false)] // Foundry Local (IPv4 loopback)
[InlineData("http://127.0.0.1:50920/v1", false)] // Foundry Local with /v1 path
[InlineData("http://[::1]:50920/", false)] // Foundry Local (IPv6 loopback)
[InlineData("http://localhost:11434/v1", false)] // Ollama-style local server
public void HealthCheckRegistrationMatchesEndpointSupport(string endpoint, bool expected)
{
var builder = Host.CreateEmptyApplicationBuilder(null);
builder.Configuration.AddInMemoryCollection([
new KeyValuePair<string, string?>("ConnectionStrings:embedding", $"Endpoint={endpoint};Key=fakekey;Model=model")
]);

builder.AddAzureEmbeddingsClient("embedding");

using var host = builder.Build();

Assert.Equal(expected, host.Services.GetService<HealthCheckService>() is not null);
}

[Theory]
[InlineData(200, HealthStatus.Healthy)]
[InlineData(500, HealthStatus.Unhealthy)]
public async Task HealthCheckReturnsExpectedStatus(int responseStatus, HealthStatus expectedStatus)
{
var transport = new MockTransport(_ => CreateResponse(responseStatus));
var builder = Host.CreateEmptyApplicationBuilder(null);
builder.Configuration.AddInMemoryCollection([
new KeyValuePair<string, string?>("ConnectionStrings:embedding", "Endpoint=https://account.services.ai.azure.com/models;Key=fakekey;Model=model")
]);

builder.AddAzureEmbeddingsClient(
"embedding",
configureClientBuilder: clientBuilder => clientBuilder.ConfigureOptions(options =>
{
options.Transport = transport;
options.Retry.MaxRetries = 0;
}));

using var host = builder.Build();
var healthCheckService = host.Services.GetRequiredService<HealthCheckService>();

var report = await healthCheckService.CheckHealthAsync();

Assert.Equal(expectedStatus, Assert.Single(report.Entries).Value.Status);
Assert.Equal("/models/info", Assert.Single(transport.Requests).Uri.Path);
}

private static MockResponse CreateResponse(int status)
{
var response = new MockResponse(status).SetContent("""
{
"model_name": "model",
"model_type": "embeddings",
"model_provider_name": "provider"
}
""");
response.AddHeader(new HttpHeader("Content-Type", "application/json"));

return response;
}

[Theory]
[InlineData("Deployment")]
[InlineData("DeploymentId")]
Expand Down
Loading
Loading