From dd4a89fe7fdb54f0cc71dcf9bfefcef94aab78f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 05:23:16 +0000 Subject: [PATCH 01/11] Initial plan From b5f8fdae032d96139fdbc2abe52f6d9af9bfde19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 05:43:25 +0000 Subject: [PATCH 02/11] Fix OpenAI health check to not use status.openai.com for custom endpoints Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs | 10 ++++ .../OpenAIExtensionTests.cs | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs index 202ac7f73ca..2fbda601d13 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs @@ -135,6 +135,16 @@ public static IResourceBuilder WithEndpoint(this IResourceBuilde ArgumentException.ThrowIfNullOrEmpty(endpoint); builder.Resource.Endpoint = endpoint; + + // Remove the StatusPage health check annotation since it's only relevant for the default OpenAI endpoint + var healthCheckKey = $"{builder.Resource.Name}_check"; + var healthCheckAnnotation = builder.Resource.Annotations.OfType() + .FirstOrDefault(a => a.Key == healthCheckKey); + if (healthCheckAnnotation is not null) + { + builder.Resource.Annotations.Remove(healthCheckAnnotation); + } + return builder; } diff --git a/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs b/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs index a456c1590cf..3ac75e47cc7 100644 --- a/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs +++ b/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs @@ -366,4 +366,50 @@ public void AddOpenAIModelWorksWithDifferentModels(string modelName) var connectionString = openai.Resource.ConnectionStringExpression.ValueExpression; Assert.Contains($"Model={modelName}", connectionString); } + + [Fact] + public void WithEndpointRemovesStatusPageHealthCheckAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key"; + + var parent = builder.AddOpenAI("openai"); + + // Verify that the StatusPage health check annotation is added by AddOpenAI + var healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); + Assert.Single(healthCheckAnnotations); + Assert.Equal("openai_check", healthCheckAnnotations[0].Key); + + // Call WithEndpoint with a custom endpoint + parent.WithEndpoint("http://localhost:12434/engines/v1"); + + // Verify that the StatusPage health check annotation is removed + healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); + Assert.Empty(healthCheckAnnotations); + } + + [Fact] + public void WithEndpointDoesNotAffectModelHealthCheck() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key"; + + var parent = builder.AddOpenAI("openai"); + var model = parent.AddModel("chat", "gpt-4o-mini"); + + // Call WithEndpoint with a custom endpoint + parent.WithEndpoint("http://localhost:12434/engines/v1"); + + // Add a model health check + model.WithHealthCheck(); + + // Verify that the model health check annotation is still present + var modelHealthCheckAnnotations = model.Resource.Annotations.OfType().ToList(); + Assert.Single(modelHealthCheckAnnotations); + Assert.Equal("chat_check", modelHealthCheckAnnotations[0].Key); + + // Verify that the parent's StatusPage health check annotation is removed + var parentHealthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); + Assert.Empty(parentHealthCheckAnnotations); + } } From 4cae8ed452465a65b74c643bc840bdd45d0dbe29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 06:13:36 +0000 Subject: [PATCH 03/11] Implement adaptive OpenAI health check based on endpoint configuration Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs | 29 ++-- .../OpenAIHealthCheck.cs | 127 ++++++++++++++++++ .../OpenAIExtensionTests.cs | 29 +--- 3 files changed, 146 insertions(+), 39 deletions(-) create mode 100644 src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs diff --git a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs index 2fbda601d13..07a6087298d 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs @@ -39,16 +39,21 @@ You can obtain an API key from the [OpenAI API Keys page](https://platform.opena defaultApiKeyParameter.WithParentRelationship(resource); - // Register the health check + // Register the adaptive health check var healthCheckKey = $"{name}_check"; - builder.AddStatusPageCheck( - healthCheckKey, - statusJsonUrl: "https://status.openai.com/api/v2/status.json", - httpClientName: "OpenAIHealthCheck", - timeout: TimeSpan.FromSeconds(5), + // Ensure IHttpClientFactory is available by registering HTTP client services + builder.Services.AddHttpClient(); + + builder.Services.AddHealthChecks().Add(new HealthCheckRegistration( + name: healthCheckKey, + factory: sp => + { + var httpFactory = sp.GetRequiredService(); + return new OpenAIHealthCheck(httpFactory, resource); + }, failureStatus: HealthStatus.Unhealthy, - tags: ["openai", "healthcheck"]); + tags: ["openai", "healthcheck"])); return builder.AddResource(resource) .WithInitialState(new() @@ -135,16 +140,6 @@ public static IResourceBuilder WithEndpoint(this IResourceBuilde ArgumentException.ThrowIfNullOrEmpty(endpoint); builder.Resource.Endpoint = endpoint; - - // Remove the StatusPage health check annotation since it's only relevant for the default OpenAI endpoint - var healthCheckKey = $"{builder.Resource.Name}_check"; - var healthCheckAnnotation = builder.Resource.Annotations.OfType() - .FirstOrDefault(a => a.Key == healthCheckKey); - if (healthCheckAnnotation is not null) - { - builder.Resource.Annotations.Remove(healthCheckAnnotation); - } - return builder; } diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs new file mode 100644 index 00000000000..6a001f676b3 --- /dev/null +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.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.Text.Json; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting.OpenAI; + +/// +/// An adaptive health check for OpenAI resources that changes behavior based on configuration. +/// +/// The HTTP client factory. +/// The OpenAI resource. +internal sealed class OpenAIHealthCheck(IHttpClientFactory httpClientFactory, OpenAIResource resource) : IHealthCheck +{ + private const string DefaultEndpoint = "https://api.openai.com/v1"; + private HealthCheckResult? _result; + + /// + /// Checks the health of the OpenAI resource. + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (_result is not null) + { + return _result.Value; + } + + try + { + // Case 1: Default endpoint - use StatusPageHealthCheck + if (resource.Endpoint == DefaultEndpoint) + { + return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); + } + + // Case 2: Custom endpoint without model health check - return healthy + // We can't check the endpoint without a model, so we just return healthy + // The model-level health check will do the actual verification if WithHealthCheck is called + _result = HealthCheckResult.Healthy("Custom OpenAI endpoint configured"); + return _result.Value; + } + catch (Exception ex) + { + _result = HealthCheckResult.Unhealthy($"Failed to check OpenAI resource: {ex.Message}", ex); + return _result.Value; + } + } + + private async Task CheckStatusPageAsync(CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient("OpenAIHealthCheck"); + var statusEndpoint = new Uri("https://status.openai.com/api/v2/status.json"); + var timeout = TimeSpan.FromSeconds(5); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var req = new HttpRequestMessage(HttpMethod.Get, statusEndpoint); + req.Headers.Accept.ParseAdd("application/json"); + + HttpResponseMessage resp; + try + { + resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException oce) when (!cancellationToken.IsCancellationRequested) + { + _result = HealthCheckResult.Unhealthy($"StatusPage request timed out after {timeout.TotalSeconds:0.#}s.", oce); + return _result.Value; + } + catch (Exception ex) + { + _result = HealthCheckResult.Unhealthy("StatusPage request failed.", ex); + return _result.Value; + } + + if (!resp.IsSuccessStatusCode) + { + _result = HealthCheckResult.Unhealthy($"StatusPage returned {(int)resp.StatusCode} {resp.ReasonPhrase}."); + return _result.Value; + } + + try + { + using var stream = await resp.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token).ConfigureAwait(false); + + if (!doc.RootElement.TryGetProperty("status", out var statusEl)) + { + _result = HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); + return _result.Value; + } + + var indicator = statusEl.TryGetProperty("indicator", out var indEl) && indEl.ValueKind == JsonValueKind.String + ? indEl.GetString() ?? string.Empty + : string.Empty; + + var description = statusEl.TryGetProperty("description", out var descEl) && descEl.ValueKind == JsonValueKind.String + ? descEl.GetString() ?? string.Empty + : string.Empty; + + var data = new Dictionary + { + ["indicator"] = indicator, + ["description"] = description, + ["endpoint"] = statusEndpoint.ToString() + }; + + _result = indicator switch + { + "none" => HealthCheckResult.Healthy(description.Length > 0 ? description : "All systems operational."), + "minor" => HealthCheckResult.Degraded(description.Length > 0 ? description : "Minor service issues."), + "major" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Major service outage."), + "critical" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Critical service outage."), + _ => HealthCheckResult.Unhealthy($"Unknown indicator '{indicator}'", data: data) + }; + + return _result.Value; + } + catch (JsonException jex) + { + _result = HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); + return _result.Value; + } + } +} diff --git a/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs b/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs index 3ac75e47cc7..a66a7a89fa1 100644 --- a/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs +++ b/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs @@ -368,48 +368,33 @@ public void AddOpenAIModelWorksWithDifferentModels(string modelName) } [Fact] - public void WithEndpointRemovesStatusPageHealthCheckAnnotation() + public void HealthCheckIsAlwaysPresent() { using var builder = TestDistributedApplicationBuilder.Create(); builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key"; var parent = builder.AddOpenAI("openai"); - // Verify that the StatusPage health check annotation is added by AddOpenAI + // Verify that the health check annotation is always added by AddOpenAI var healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); Assert.Single(healthCheckAnnotations); Assert.Equal("openai_check", healthCheckAnnotations[0].Key); - - // Call WithEndpoint with a custom endpoint - parent.WithEndpoint("http://localhost:12434/engines/v1"); - - // Verify that the StatusPage health check annotation is removed - healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); - Assert.Empty(healthCheckAnnotations); } [Fact] - public void WithEndpointDoesNotAffectModelHealthCheck() + public void HealthCheckRemainsAfterWithEndpoint() { using var builder = TestDistributedApplicationBuilder.Create(); builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key"; var parent = builder.AddOpenAI("openai"); - var model = parent.AddModel("chat", "gpt-4o-mini"); // Call WithEndpoint with a custom endpoint parent.WithEndpoint("http://localhost:12434/engines/v1"); - // Add a model health check - model.WithHealthCheck(); - - // Verify that the model health check annotation is still present - var modelHealthCheckAnnotations = model.Resource.Annotations.OfType().ToList(); - Assert.Single(modelHealthCheckAnnotations); - Assert.Equal("chat_check", modelHealthCheckAnnotations[0].Key); - - // Verify that the parent's StatusPage health check annotation is removed - var parentHealthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); - Assert.Empty(parentHealthCheckAnnotations); + // Verify that the health check annotation is still present (adaptive health check) + var healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); + Assert.Single(healthCheckAnnotations); + Assert.Equal("openai_check", healthCheckAnnotations[0].Key); } } From 1399972c104af12960b73faed37436416a8f988c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 06:33:37 +0000 Subject: [PATCH 04/11] Refactor to use adaptive health check with state tracking on OpenAIResource Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs | 5 + .../OpenAIHealthCheck.cs | 128 +++++++++++++++++- src/Aspire.Hosting.OpenAI/OpenAIResource.cs | 10 ++ 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs index 07a6087298d..0d5162a848f 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs @@ -198,6 +198,11 @@ public static IResourceBuilder WithHealthCheck(this IResour // Ensure IHttpClientFactory is available by registering HTTP client services builder.ApplicationBuilder.Services.AddHttpClient(); + // Configure the parent OpenAI resource to use model health check + var parentResource = builder.Resource.Parent; + parentResource.UseModelHealthCheck = true; + parentResource.ModelConnectionString = async () => await builder.Resource.ConnectionStringExpression.GetValueAsync(default).ConfigureAwait(false); + // Register the health check builder.ApplicationBuilder.Services.AddHealthChecks() .Add(new HealthCheckRegistration( diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index 6a001f676b3..d2765b1636f 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -1,7 +1,10 @@ // 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.OpenAI; @@ -28,17 +31,20 @@ public async Task CheckHealthAsync(HealthCheckContext context try { - // Case 1: Default endpoint - use StatusPageHealthCheck + // Case 1: Default endpoint - use StatusPage check if (resource.Endpoint == DefaultEndpoint) { return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); } - // Case 2: Custom endpoint without model health check - return healthy - // We can't check the endpoint without a model, so we just return healthy - // The model-level health check will do the actual verification if WithHealthCheck is called - _result = HealthCheckResult.Healthy("Custom OpenAI endpoint configured"); - return _result.Value; + // Case 2: Custom endpoint with model health check - use model health check + if (resource.UseModelHealthCheck && resource.ModelConnectionString is not null) + { + return await CheckModelHealthAsync(cancellationToken).ConfigureAwait(false); + } + + // Case 3: Custom endpoint without model health check - return healthy + return await CheckEndpointHealthAsync().ConfigureAwait(false); } catch (Exception ex) { @@ -47,6 +53,9 @@ public async Task CheckHealthAsync(HealthCheckContext context } } + /// + /// Checks the StatusPage endpoint for the default OpenAI service. + /// private async Task CheckStatusPageAsync(CancellationToken cancellationToken) { var client = httpClientFactory.CreateClient("OpenAIHealthCheck"); @@ -86,6 +95,11 @@ private async Task CheckStatusPageAsync(CancellationToken can using var stream = await resp.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token).ConfigureAwait(false); + // Expected shape: + // { + // "page": { ... }, + // "status": { "indicator": "none|minor|major|critical", "description": "..." } + // } if (!doc.RootElement.TryGetProperty("status", out var statusEl)) { _result = HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); @@ -107,6 +121,7 @@ private async Task CheckStatusPageAsync(CancellationToken can ["endpoint"] = statusEndpoint.ToString() }; + // Map indicator -> HealthStatus _result = indicator switch { "none" => HealthCheckResult.Healthy(description.Length > 0 ? description : "All systems operational."), @@ -124,4 +139,105 @@ private async Task CheckStatusPageAsync(CancellationToken can return _result.Value; } } + + /// + /// Returns healthy for custom endpoints when no model health check is configured. + /// + private Task CheckEndpointHealthAsync() + { + _result = HealthCheckResult.Healthy("Custom OpenAI endpoint configured"); + return Task.FromResult(_result.Value); + } + + /// + /// Checks the health of the OpenAI endpoint by sending a test request to the model endpoint. + /// + private async Task CheckModelHealthAsync(CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient("OpenAIHealthCheck"); + var connectionString = resource.ModelConnectionString; + + if (connectionString is null) + { + _result = HealthCheckResult.Unhealthy("Model connection string not available"); + return _result.Value; + } + + try + { + var builder = new DbConnectionStringBuilder() { ConnectionString = await connectionString().ConfigureAwait(false) }; + var endpoint = builder["Endpoint"]; + var model = builder["Model"]; + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"{endpoint}/models/{model}")); + + // Add required headers + request.Headers.Add("Authorization", $"Bearer {builder["Key"]}"); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + _result = response.StatusCode switch + { + HttpStatusCode.OK => HealthCheckResult.Healthy(), + HttpStatusCode.Unauthorized => HealthCheckResult.Unhealthy("OpenAI API key is invalid"), + HttpStatusCode.NotFound => await HandleNotFound(response, cancellationToken).ConfigureAwait(false), + HttpStatusCode.TooManyRequests => HealthCheckResult.Unhealthy("OpenAI API rate limit exceeded"), + _ => HealthCheckResult.Unhealthy($"OpenAI endpoint returned unexpected status code: {response.StatusCode}") + }; + } + catch (Exception ex) + { + _result = HealthCheckResult.Unhealthy($"Failed to check OpenAI endpoint: {ex.Message}", ex); + } + + return _result.Value; + } + + private static async Task HandleNotFound(HttpResponseMessage response, CancellationToken cancellationToken) + { + OpenAIErrorResponse? errorResponse = null; + + try + { + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + errorResponse = JsonSerializer.Deserialize(content); + + if (errorResponse?.Error?.Code == "model_not_found") + { + var message = !string.IsNullOrEmpty(errorResponse.Error.Message) + ? errorResponse.Error.Message + : "Model not found"; + return HealthCheckResult.Unhealthy($"OpenAI: {message}"); + } + } + catch + { + } + + return HealthCheckResult.Unhealthy($"OpenAI returned an unsupported response: ({response.StatusCode}) {errorResponse?.Error?.Message}"); + } + + /// + /// Represents the error response from OpenAI API. + /// + private sealed class OpenAIErrorResponse + { + [JsonPropertyName("error")] + public OpenAIError? Error { get; set; } + } + + /// + /// Represents an error from OpenAI API. + /// + private sealed class OpenAIError + { + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + } } diff --git a/src/Aspire.Hosting.OpenAI/OpenAIResource.cs b/src/Aspire.Hosting.OpenAI/OpenAIResource.cs index 30d981d77dd..86a2d9b7365 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIResource.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIResource.cs @@ -19,6 +19,16 @@ public sealed class OpenAIResource : Resource, IResourceWithConnectionString /// public string Endpoint { get; internal set; } = DefaultEndpoint; + /// + /// Gets or sets whether a model health check is enabled for this resource. + /// + internal bool UseModelHealthCheck { get; set; } + + /// + /// Gets or sets the connection string provider for model health check. + /// + internal Func>? ModelConnectionString { get; set; } + /// /// Creates a new . /// From 54f5e804691052fd89951ff1ee3bfc05aa458324 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 06:35:17 +0000 Subject: [PATCH 05/11] Remove StatusPageHealthCheck.cs as it's been merged into OpenAIHealthCheck Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../StatusPageHealthCheck.cs | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 src/Aspire.Hosting.OpenAI/StatusPageHealthCheck.cs diff --git a/src/Aspire.Hosting.OpenAI/StatusPageHealthCheck.cs b/src/Aspire.Hosting.OpenAI/StatusPageHealthCheck.cs deleted file mode 100644 index 6756c83a02f..00000000000 --- a/src/Aspire.Hosting.OpenAI/StatusPageHealthCheck.cs +++ /dev/null @@ -1,150 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; - -namespace Aspire.Hosting.OpenAI; - -/// -/// Checks a StatusPage "status.json" endpoint and maps indicator to ASP.NET Core health status. -/// -internal sealed class StatusPageHealthCheck : IHealthCheck -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly Uri _statusEndpoint; - private readonly string? _httpClientName; - private readonly TimeSpan _timeout; - - /// - /// Initializes a new instance of the class. - /// - /// The factory to create HTTP clients. - /// The URI of the status.json endpoint. - /// The optional name of the HTTP client to use. - /// The optional timeout for the HTTP request. - public StatusPageHealthCheck( - IHttpClientFactory httpClientFactory, - Uri statusEndpoint, - string? httpClientName = null, - TimeSpan? timeout = null) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _statusEndpoint = statusEndpoint ?? throw new ArgumentNullException(nameof(statusEndpoint)); - _httpClientName = httpClientName; - _timeout = timeout ?? TimeSpan.FromSeconds(5); - } - - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - var client = string.IsNullOrWhiteSpace(_httpClientName) - ? _httpClientFactory.CreateClient() - : _httpClientFactory.CreateClient(_httpClientName); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(_timeout); - - using var req = new HttpRequestMessage(HttpMethod.Get, _statusEndpoint); - req.Headers.Accept.ParseAdd("application/json"); - - HttpResponseMessage resp; - try - { - resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException oce) when (!cancellationToken.IsCancellationRequested) - { - return HealthCheckResult.Unhealthy($"StatusPage request timed out after {_timeout.TotalSeconds:0.#}s.", oce); - } - catch (Exception ex) - { - return HealthCheckResult.Unhealthy("StatusPage request failed.", ex); - } - - if (!resp.IsSuccessStatusCode) - { - return HealthCheckResult.Unhealthy($"StatusPage returned {(int)resp.StatusCode} {resp.ReasonPhrase}."); - } - - try - { - using var stream = await resp.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token).ConfigureAwait(false); - - // Expected shape: - // { - // "page": { ... }, - // "status": { "indicator": "none|minor|major|critical", "description": "..." } - // } - if (!doc.RootElement.TryGetProperty("status", out var statusEl)) - { - return HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); - } - - var indicator = statusEl.TryGetProperty("indicator", out var indEl) && indEl.ValueKind == JsonValueKind.String - ? indEl.GetString() ?? string.Empty - : string.Empty; - - var description = statusEl.TryGetProperty("description", out var descEl) && descEl.ValueKind == JsonValueKind.String - ? descEl.GetString() ?? string.Empty - : string.Empty; - - var data = new Dictionary - { - ["indicator"] = indicator, - ["description"] = description, - ["endpoint"] = _statusEndpoint.ToString() - }; - - // Map indicator -> HealthStatus - return indicator switch - { - "none" => HealthCheckResult.Healthy(description.Length > 0 ? description : "All systems operational."), - "minor" => HealthCheckResult.Degraded(description.Length > 0 ? description : "Minor service issues."), - "major" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Major service outage."), - "critical" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Critical service outage."), - _ => HealthCheckResult.Unhealthy($"Unknown indicator '{indicator}'", data: data) - }; - } - catch (JsonException jex) - { - return HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); - } - } -} - -internal static class StatuspageHealthCheckExtensions -{ - /// - /// Registers a StatusPage health check for a given status.json URL. - /// - public static IDistributedApplicationBuilder AddStatusPageCheck( - this IDistributedApplicationBuilder builder, - string name, - string statusJsonUrl, - string? httpClientName = null, - TimeSpan? timeout = null, - HealthStatus? failureStatus = null, - IEnumerable? tags = null) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(statusJsonUrl); - - // Ensure IHttpClientFactory is available by registering HTTP client services - builder.Services.AddHttpClient(); - - builder.Services.AddHealthChecks().Add(new HealthCheckRegistration( - name: name, - factory: sp => - { - var httpFactory = sp.GetRequiredService(); - return new StatusPageHealthCheck(httpFactory, new Uri(statusJsonUrl), httpClientName, timeout); - }, - failureStatus: failureStatus, - tags: tags)); - - return builder; - } -} From b00e4892e7252bd1f2022ca6215d48a4e76e76e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 06:54:03 +0000 Subject: [PATCH 06/11] Simplify to use adaptive OpenAIHealthCheck with 2 cases Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs | 9 +- .../OpenAIHealthCheck.cs | 187 ++++-------------- src/Aspire.Hosting.OpenAI/OpenAIResource.cs | 10 - .../OpenAIExtensionTests.cs | 31 --- 4 files changed, 44 insertions(+), 193 deletions(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs index 0d5162a848f..776690dd619 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs @@ -39,7 +39,7 @@ You can obtain an API key from the [OpenAI API Keys page](https://platform.opena defaultApiKeyParameter.WithParentRelationship(resource); - // Register the adaptive health check + // Register the health check var healthCheckKey = $"{name}_check"; // Ensure IHttpClientFactory is available by registering HTTP client services @@ -50,7 +50,7 @@ You can obtain an API key from the [OpenAI API Keys page](https://platform.opena factory: sp => { var httpFactory = sp.GetRequiredService(); - return new OpenAIHealthCheck(httpFactory, resource); + return new OpenAIHealthCheck(httpFactory, resource, "OpenAIHealthCheck", TimeSpan.FromSeconds(5)); }, failureStatus: HealthStatus.Unhealthy, tags: ["openai", "healthcheck"])); @@ -198,11 +198,6 @@ public static IResourceBuilder WithHealthCheck(this IResour // Ensure IHttpClientFactory is available by registering HTTP client services builder.ApplicationBuilder.Services.AddHttpClient(); - // Configure the parent OpenAI resource to use model health check - var parentResource = builder.Resource.Parent; - parentResource.UseModelHealthCheck = true; - parentResource.ModelConnectionString = async () => await builder.Resource.ConnectionStringExpression.GetValueAsync(default).ConfigureAwait(false); - // Register the health check builder.ApplicationBuilder.Services.AddHealthChecks() .Add(new HealthCheckRegistration( diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index d2765b1636f..0bc1ea3ec7e 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -1,56 +1,51 @@ // 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.OpenAI; /// -/// An adaptive health check for OpenAI resources that changes behavior based on configuration. +/// Health check for OpenAI resources that adapts based on endpoint configuration. /// -/// The HTTP client factory. -/// The OpenAI resource. -internal sealed class OpenAIHealthCheck(IHttpClientFactory httpClientFactory, OpenAIResource resource) : IHealthCheck +internal sealed class OpenAIHealthCheck : IHealthCheck { private const string DefaultEndpoint = "https://api.openai.com/v1"; - private HealthCheckResult? _result; + private readonly IHttpClientFactory _httpClientFactory; + private readonly OpenAIResource _resource; + private readonly string? _httpClientName; + private readonly TimeSpan _timeout; /// - /// Checks the health of the OpenAI resource. + /// Initializes a new instance of the class. /// + /// The factory to create HTTP clients. + /// The OpenAI resource. + /// The optional name of the HTTP client to use. + /// The optional timeout for the HTTP request. + public OpenAIHealthCheck( + IHttpClientFactory httpClientFactory, + OpenAIResource resource, + string? httpClientName = null, + TimeSpan? timeout = null) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _resource = resource ?? throw new ArgumentNullException(nameof(resource)); + _httpClientName = httpClientName; + _timeout = timeout ?? TimeSpan.FromSeconds(5); + } + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - if (_result is not null) + // Case 1: Default endpoint - check StatusPage + if (_resource.Endpoint == DefaultEndpoint) { - return _result.Value; + return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); } - try - { - // Case 1: Default endpoint - use StatusPage check - if (resource.Endpoint == DefaultEndpoint) - { - return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); - } - - // Case 2: Custom endpoint with model health check - use model health check - if (resource.UseModelHealthCheck && resource.ModelConnectionString is not null) - { - return await CheckModelHealthAsync(cancellationToken).ConfigureAwait(false); - } - - // Case 3: Custom endpoint without model health check - return healthy - return await CheckEndpointHealthAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - _result = HealthCheckResult.Unhealthy($"Failed to check OpenAI resource: {ex.Message}", ex); - return _result.Value; - } + // Case 2: Custom endpoint - return healthy + return await CheckEndpointHealthAsync().ConfigureAwait(false); } /// @@ -58,12 +53,14 @@ public async Task CheckHealthAsync(HealthCheckContext context /// private async Task CheckStatusPageAsync(CancellationToken cancellationToken) { - var client = httpClientFactory.CreateClient("OpenAIHealthCheck"); + var client = string.IsNullOrWhiteSpace(_httpClientName) + ? _httpClientFactory.CreateClient() + : _httpClientFactory.CreateClient(_httpClientName); + var statusEndpoint = new Uri("https://status.openai.com/api/v2/status.json"); - var timeout = TimeSpan.FromSeconds(5); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(timeout); + cts.CancelAfter(_timeout); using var req = new HttpRequestMessage(HttpMethod.Get, statusEndpoint); req.Headers.Accept.ParseAdd("application/json"); @@ -75,19 +72,16 @@ private async Task CheckStatusPageAsync(CancellationToken can } catch (OperationCanceledException oce) when (!cancellationToken.IsCancellationRequested) { - _result = HealthCheckResult.Unhealthy($"StatusPage request timed out after {timeout.TotalSeconds:0.#}s.", oce); - return _result.Value; + return HealthCheckResult.Unhealthy($"StatusPage request timed out after {_timeout.TotalSeconds:0.#}s.", oce); } catch (Exception ex) { - _result = HealthCheckResult.Unhealthy("StatusPage request failed.", ex); - return _result.Value; + return HealthCheckResult.Unhealthy("StatusPage request failed.", ex); } if (!resp.IsSuccessStatusCode) { - _result = HealthCheckResult.Unhealthy($"StatusPage returned {(int)resp.StatusCode} {resp.ReasonPhrase}."); - return _result.Value; + return HealthCheckResult.Unhealthy($"StatusPage returned {(int)resp.StatusCode} {resp.ReasonPhrase}."); } try @@ -102,8 +96,7 @@ private async Task CheckStatusPageAsync(CancellationToken can // } if (!doc.RootElement.TryGetProperty("status", out var statusEl)) { - _result = HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); - return _result.Value; + return HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); } var indicator = statusEl.TryGetProperty("indicator", out var indEl) && indEl.ValueKind == JsonValueKind.String @@ -122,7 +115,7 @@ private async Task CheckStatusPageAsync(CancellationToken can }; // Map indicator -> HealthStatus - _result = indicator switch + return indicator switch { "none" => HealthCheckResult.Healthy(description.Length > 0 ? description : "All systems operational."), "minor" => HealthCheckResult.Degraded(description.Length > 0 ? description : "Minor service issues."), @@ -130,114 +123,18 @@ private async Task CheckStatusPageAsync(CancellationToken can "critical" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Critical service outage."), _ => HealthCheckResult.Unhealthy($"Unknown indicator '{indicator}'", data: data) }; - - return _result.Value; } catch (JsonException jex) { - _result = HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); - return _result.Value; - } - } - - /// - /// Returns healthy for custom endpoints when no model health check is configured. - /// - private Task CheckEndpointHealthAsync() - { - _result = HealthCheckResult.Healthy("Custom OpenAI endpoint configured"); - return Task.FromResult(_result.Value); - } - - /// - /// Checks the health of the OpenAI endpoint by sending a test request to the model endpoint. - /// - private async Task CheckModelHealthAsync(CancellationToken cancellationToken) - { - var httpClient = httpClientFactory.CreateClient("OpenAIHealthCheck"); - var connectionString = resource.ModelConnectionString; - - if (connectionString is null) - { - _result = HealthCheckResult.Unhealthy("Model connection string not available"); - return _result.Value; - } - - try - { - var builder = new DbConnectionStringBuilder() { ConnectionString = await connectionString().ConfigureAwait(false) }; - var endpoint = builder["Endpoint"]; - var model = builder["Model"]; - - using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"{endpoint}/models/{model}")); - - // Add required headers - request.Headers.Add("Authorization", $"Bearer {builder["Key"]}"); - - using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - - _result = response.StatusCode switch - { - HttpStatusCode.OK => HealthCheckResult.Healthy(), - HttpStatusCode.Unauthorized => HealthCheckResult.Unhealthy("OpenAI API key is invalid"), - HttpStatusCode.NotFound => await HandleNotFound(response, cancellationToken).ConfigureAwait(false), - HttpStatusCode.TooManyRequests => HealthCheckResult.Unhealthy("OpenAI API rate limit exceeded"), - _ => HealthCheckResult.Unhealthy($"OpenAI endpoint returned unexpected status code: {response.StatusCode}") - }; - } - catch (Exception ex) - { - _result = HealthCheckResult.Unhealthy($"Failed to check OpenAI endpoint: {ex.Message}", ex); - } - - return _result.Value; - } - - private static async Task HandleNotFound(HttpResponseMessage response, CancellationToken cancellationToken) - { - OpenAIErrorResponse? errorResponse = null; - - try - { - var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - errorResponse = JsonSerializer.Deserialize(content); - - if (errorResponse?.Error?.Code == "model_not_found") - { - var message = !string.IsNullOrEmpty(errorResponse.Error.Message) - ? errorResponse.Error.Message - : "Model not found"; - return HealthCheckResult.Unhealthy($"OpenAI: {message}"); - } - } - catch - { + return HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); } - - return HealthCheckResult.Unhealthy($"OpenAI returned an unsupported response: ({response.StatusCode}) {errorResponse?.Error?.Message}"); - } - - /// - /// Represents the error response from OpenAI API. - /// - private sealed class OpenAIErrorResponse - { - [JsonPropertyName("error")] - public OpenAIError? Error { get; set; } } /// - /// Represents an error from OpenAI API. + /// Returns healthy for custom endpoints. /// - private sealed class OpenAIError + private static Task CheckEndpointHealthAsync() { - [JsonPropertyName("code")] - public string? Code { get; set; } - - [JsonPropertyName("message")] - public string? Message { get; set; } - - [JsonPropertyName("type")] - public string? Type { get; set; } + return Task.FromResult(HealthCheckResult.Healthy("Custom OpenAI endpoint configured")); } } diff --git a/src/Aspire.Hosting.OpenAI/OpenAIResource.cs b/src/Aspire.Hosting.OpenAI/OpenAIResource.cs index 86a2d9b7365..30d981d77dd 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIResource.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIResource.cs @@ -19,16 +19,6 @@ public sealed class OpenAIResource : Resource, IResourceWithConnectionString /// public string Endpoint { get; internal set; } = DefaultEndpoint; - /// - /// Gets or sets whether a model health check is enabled for this resource. - /// - internal bool UseModelHealthCheck { get; set; } - - /// - /// Gets or sets the connection string provider for model health check. - /// - internal Func>? ModelConnectionString { get; set; } - /// /// Creates a new . /// diff --git a/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs b/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs index a66a7a89fa1..a456c1590cf 100644 --- a/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs +++ b/tests/Aspire.Hosting.OpenAI.Tests/OpenAIExtensionTests.cs @@ -366,35 +366,4 @@ public void AddOpenAIModelWorksWithDifferentModels(string modelName) var connectionString = openai.Resource.ConnectionStringExpression.ValueExpression; Assert.Contains($"Model={modelName}", connectionString); } - - [Fact] - public void HealthCheckIsAlwaysPresent() - { - using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key"; - - var parent = builder.AddOpenAI("openai"); - - // Verify that the health check annotation is always added by AddOpenAI - var healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); - Assert.Single(healthCheckAnnotations); - Assert.Equal("openai_check", healthCheckAnnotations[0].Key); - } - - [Fact] - public void HealthCheckRemainsAfterWithEndpoint() - { - using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["Parameters:openai-openai-apikey"] = "test-api-key"; - - var parent = builder.AddOpenAI("openai"); - - // Call WithEndpoint with a custom endpoint - parent.WithEndpoint("http://localhost:12434/engines/v1"); - - // Verify that the health check annotation is still present (adaptive health check) - var healthCheckAnnotations = parent.Resource.Annotations.OfType().ToList(); - Assert.Single(healthCheckAnnotations); - Assert.Equal("openai_check", healthCheckAnnotations[0].Key); - } } From 4d2152b04bd966c69331647a88dfbef07906ce54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 07:11:11 +0000 Subject: [PATCH 07/11] Remove StatusPage health check logic, return healthy for all cases Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs | 9 +- .../OpenAIHealthCheck.cs | 128 +----------------- 2 files changed, 5 insertions(+), 132 deletions(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs index 776690dd619..9563d4b5daa 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs @@ -42,16 +42,9 @@ You can obtain an API key from the [OpenAI API Keys page](https://platform.opena // Register the health check var healthCheckKey = $"{name}_check"; - // Ensure IHttpClientFactory is available by registering HTTP client services - builder.Services.AddHttpClient(); - builder.Services.AddHealthChecks().Add(new HealthCheckRegistration( name: healthCheckKey, - factory: sp => - { - var httpFactory = sp.GetRequiredService(); - return new OpenAIHealthCheck(httpFactory, resource, "OpenAIHealthCheck", TimeSpan.FromSeconds(5)); - }, + factory: sp => new OpenAIHealthCheck(), failureStatus: HealthStatus.Unhealthy, tags: ["openai", "healthcheck"])); diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index 0bc1ea3ec7e..b6444d67498 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -1,140 +1,20 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting.OpenAI; /// -/// Health check for OpenAI resources that adapts based on endpoint configuration. +/// Health check for OpenAI resources. /// internal sealed class OpenAIHealthCheck : IHealthCheck { - private const string DefaultEndpoint = "https://api.openai.com/v1"; - private readonly IHttpClientFactory _httpClientFactory; - private readonly OpenAIResource _resource; - private readonly string? _httpClientName; - private readonly TimeSpan _timeout; - - /// - /// Initializes a new instance of the class. - /// - /// The factory to create HTTP clients. - /// The OpenAI resource. - /// The optional name of the HTTP client to use. - /// The optional timeout for the HTTP request. - public OpenAIHealthCheck( - IHttpClientFactory httpClientFactory, - OpenAIResource resource, - string? httpClientName = null, - TimeSpan? timeout = null) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _resource = resource ?? throw new ArgumentNullException(nameof(resource)); - _httpClientName = httpClientName; - _timeout = timeout ?? TimeSpan.FromSeconds(5); - } - - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - // Case 1: Default endpoint - check StatusPage - if (_resource.Endpoint == DefaultEndpoint) - { - return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); - } - - // Case 2: Custom endpoint - return healthy - return await CheckEndpointHealthAsync().ConfigureAwait(false); - } - - /// - /// Checks the StatusPage endpoint for the default OpenAI service. - /// - private async Task CheckStatusPageAsync(CancellationToken cancellationToken) - { - var client = string.IsNullOrWhiteSpace(_httpClientName) - ? _httpClientFactory.CreateClient() - : _httpClientFactory.CreateClient(_httpClientName); - - var statusEndpoint = new Uri("https://status.openai.com/api/v2/status.json"); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(_timeout); - - using var req = new HttpRequestMessage(HttpMethod.Get, statusEndpoint); - req.Headers.Accept.ParseAdd("application/json"); - - HttpResponseMessage resp; - try - { - resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException oce) when (!cancellationToken.IsCancellationRequested) - { - return HealthCheckResult.Unhealthy($"StatusPage request timed out after {_timeout.TotalSeconds:0.#}s.", oce); - } - catch (Exception ex) - { - return HealthCheckResult.Unhealthy("StatusPage request failed.", ex); - } - - if (!resp.IsSuccessStatusCode) - { - return HealthCheckResult.Unhealthy($"StatusPage returned {(int)resp.StatusCode} {resp.ReasonPhrase}."); - } - - try - { - using var stream = await resp.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token).ConfigureAwait(false); - - // Expected shape: - // { - // "page": { ... }, - // "status": { "indicator": "none|minor|major|critical", "description": "..." } - // } - if (!doc.RootElement.TryGetProperty("status", out var statusEl)) - { - return HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); - } - - var indicator = statusEl.TryGetProperty("indicator", out var indEl) && indEl.ValueKind == JsonValueKind.String - ? indEl.GetString() ?? string.Empty - : string.Empty; - - var description = statusEl.TryGetProperty("description", out var descEl) && descEl.ValueKind == JsonValueKind.String - ? descEl.GetString() ?? string.Empty - : string.Empty; - - var data = new Dictionary - { - ["indicator"] = indicator, - ["description"] = description, - ["endpoint"] = statusEndpoint.ToString() - }; - - // Map indicator -> HealthStatus - return indicator switch - { - "none" => HealthCheckResult.Healthy(description.Length > 0 ? description : "All systems operational."), - "minor" => HealthCheckResult.Degraded(description.Length > 0 ? description : "Minor service issues."), - "major" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Major service outage."), - "critical" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Critical service outage."), - _ => HealthCheckResult.Unhealthy($"Unknown indicator '{indicator}'", data: data) - }; - } - catch (JsonException jex) - { - return HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); - } - } - /// - /// Returns healthy for custom endpoints. + /// Checks the health of the OpenAI resource. /// - private static Task CheckEndpointHealthAsync() + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - return Task.FromResult(HealthCheckResult.Healthy("Custom OpenAI endpoint configured")); + return Task.FromResult(HealthCheckResult.Healthy()); } } From df2df9bbbd349ad81e1da77c52bbe17dfa5bfd4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 07:26:09 +0000 Subject: [PATCH 08/11] Restore OpenAIHealthCheck with CheckStatusPageAsync and CheckEndpointHealthAsync methods Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs | 9 +- .../OpenAIHealthCheck.cs | 128 +++++++++++++++++- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs index 9563d4b5daa..776690dd619 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs @@ -42,9 +42,16 @@ You can obtain an API key from the [OpenAI API Keys page](https://platform.opena // Register the health check var healthCheckKey = $"{name}_check"; + // Ensure IHttpClientFactory is available by registering HTTP client services + builder.Services.AddHttpClient(); + builder.Services.AddHealthChecks().Add(new HealthCheckRegistration( name: healthCheckKey, - factory: sp => new OpenAIHealthCheck(), + factory: sp => + { + var httpFactory = sp.GetRequiredService(); + return new OpenAIHealthCheck(httpFactory, resource, "OpenAIHealthCheck", TimeSpan.FromSeconds(5)); + }, failureStatus: HealthStatus.Unhealthy, tags: ["openai", "healthcheck"])); diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index b6444d67498..0bc1ea3ec7e 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -1,20 +1,140 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting.OpenAI; /// -/// Health check for OpenAI resources. +/// Health check for OpenAI resources that adapts based on endpoint configuration. /// internal sealed class OpenAIHealthCheck : IHealthCheck { + private const string DefaultEndpoint = "https://api.openai.com/v1"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly OpenAIResource _resource; + private readonly string? _httpClientName; + private readonly TimeSpan _timeout; + + /// + /// Initializes a new instance of the class. + /// + /// The factory to create HTTP clients. + /// The OpenAI resource. + /// The optional name of the HTTP client to use. + /// The optional timeout for the HTTP request. + public OpenAIHealthCheck( + IHttpClientFactory httpClientFactory, + OpenAIResource resource, + string? httpClientName = null, + TimeSpan? timeout = null) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _resource = resource ?? throw new ArgumentNullException(nameof(resource)); + _httpClientName = httpClientName; + _timeout = timeout ?? TimeSpan.FromSeconds(5); + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + // Case 1: Default endpoint - check StatusPage + if (_resource.Endpoint == DefaultEndpoint) + { + return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); + } + + // Case 2: Custom endpoint - return healthy + return await CheckEndpointHealthAsync().ConfigureAwait(false); + } + + /// + /// Checks the StatusPage endpoint for the default OpenAI service. + /// + private async Task CheckStatusPageAsync(CancellationToken cancellationToken) + { + var client = string.IsNullOrWhiteSpace(_httpClientName) + ? _httpClientFactory.CreateClient() + : _httpClientFactory.CreateClient(_httpClientName); + + var statusEndpoint = new Uri("https://status.openai.com/api/v2/status.json"); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_timeout); + + using var req = new HttpRequestMessage(HttpMethod.Get, statusEndpoint); + req.Headers.Accept.ParseAdd("application/json"); + + HttpResponseMessage resp; + try + { + resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException oce) when (!cancellationToken.IsCancellationRequested) + { + return HealthCheckResult.Unhealthy($"StatusPage request timed out after {_timeout.TotalSeconds:0.#}s.", oce); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("StatusPage request failed.", ex); + } + + if (!resp.IsSuccessStatusCode) + { + return HealthCheckResult.Unhealthy($"StatusPage returned {(int)resp.StatusCode} {resp.ReasonPhrase}."); + } + + try + { + using var stream = await resp.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token).ConfigureAwait(false); + + // Expected shape: + // { + // "page": { ... }, + // "status": { "indicator": "none|minor|major|critical", "description": "..." } + // } + if (!doc.RootElement.TryGetProperty("status", out var statusEl)) + { + return HealthCheckResult.Unhealthy("Missing 'status' object in StatusPage response."); + } + + var indicator = statusEl.TryGetProperty("indicator", out var indEl) && indEl.ValueKind == JsonValueKind.String + ? indEl.GetString() ?? string.Empty + : string.Empty; + + var description = statusEl.TryGetProperty("description", out var descEl) && descEl.ValueKind == JsonValueKind.String + ? descEl.GetString() ?? string.Empty + : string.Empty; + + var data = new Dictionary + { + ["indicator"] = indicator, + ["description"] = description, + ["endpoint"] = statusEndpoint.ToString() + }; + + // Map indicator -> HealthStatus + return indicator switch + { + "none" => HealthCheckResult.Healthy(description.Length > 0 ? description : "All systems operational."), + "minor" => HealthCheckResult.Degraded(description.Length > 0 ? description : "Minor service issues."), + "major" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Major service outage."), + "critical" => HealthCheckResult.Unhealthy(description.Length > 0 ? description : "Critical service outage."), + _ => HealthCheckResult.Unhealthy($"Unknown indicator '{indicator}'", data: data) + }; + } + catch (JsonException jex) + { + return HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); + } + } + /// - /// Checks the health of the OpenAI resource. + /// Returns healthy for custom endpoints. /// - public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + private static Task CheckEndpointHealthAsync() { - return Task.FromResult(HealthCheckResult.Healthy()); + return Task.FromResult(HealthCheckResult.Healthy("Custom OpenAI endpoint configured")); } } From b61040e3952f5fa39054529db9bc4c583dc0c154 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 1 Oct 2025 00:50:23 -0700 Subject: [PATCH 09/11] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index 0bc1ea3ec7e..26f453f00a9 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -39,7 +39,9 @@ public OpenAIHealthCheck( public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { // Case 1: Default endpoint - check StatusPage - if (_resource.Endpoint == DefaultEndpoint) + if (Uri.TryCreate(_resource.Endpoint, UriKind.Absolute, out var endpointUri) && + Uri.TryCreate(DefaultEndpoint, UriKind.Absolute, out var defaultUri) && + Uri.Compare(endpointUri, defaultUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) { return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); } From 8050e741c3046cc925eb4a2a0700db83516baff6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:58:25 +0000 Subject: [PATCH 10/11] Make DefaultEndpoint and StatusPage Uri instances static readonly fields Co-authored-by: sebastienros <1165805+sebastienros@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index 26f453f00a9..6f26eb2bced 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -11,7 +11,9 @@ namespace Aspire.Hosting.OpenAI; /// internal sealed class OpenAIHealthCheck : IHealthCheck { - private const string DefaultEndpoint = "https://api.openai.com/v1"; + private static readonly Uri s_defaultEndpointUri = new("https://api.openai.com/v1"); + private static readonly Uri s_statusPageUri = new("https://status.openai.com/api/v2/status.json"); + private readonly IHttpClientFactory _httpClientFactory; private readonly OpenAIResource _resource; private readonly string? _httpClientName; @@ -40,8 +42,7 @@ public async Task CheckHealthAsync(HealthCheckContext context { // Case 1: Default endpoint - check StatusPage if (Uri.TryCreate(_resource.Endpoint, UriKind.Absolute, out var endpointUri) && - Uri.TryCreate(DefaultEndpoint, UriKind.Absolute, out var defaultUri) && - Uri.Compare(endpointUri, defaultUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) + Uri.Compare(endpointUri, s_defaultEndpointUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) { return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); } @@ -59,12 +60,10 @@ private async Task CheckStatusPageAsync(CancellationToken can ? _httpClientFactory.CreateClient() : _httpClientFactory.CreateClient(_httpClientName); - var statusEndpoint = new Uri("https://status.openai.com/api/v2/status.json"); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_timeout); - using var req = new HttpRequestMessage(HttpMethod.Get, statusEndpoint); + using var req = new HttpRequestMessage(HttpMethod.Get, s_statusPageUri); req.Headers.Accept.ParseAdd("application/json"); HttpResponseMessage resp; @@ -113,7 +112,7 @@ private async Task CheckStatusPageAsync(CancellationToken can { ["indicator"] = indicator, ["description"] = description, - ["endpoint"] = statusEndpoint.ToString() + ["endpoint"] = s_statusPageUri.ToString() }; // Map indicator -> HealthStatus From c8a3897546f57e9ba72d232081beb957666d9ea3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:32:24 +0000 Subject: [PATCH 11/11] Inline CheckEndpointHealthAsync method Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs index 6f26eb2bced..ec4e8e5a06f 100644 --- a/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs +++ b/src/Aspire.Hosting.OpenAI/OpenAIHealthCheck.cs @@ -48,7 +48,7 @@ public async Task CheckHealthAsync(HealthCheckContext context } // Case 2: Custom endpoint - return healthy - return await CheckEndpointHealthAsync().ConfigureAwait(false); + return HealthCheckResult.Healthy("Custom OpenAI endpoint configured"); } /// @@ -130,12 +130,4 @@ private async Task CheckStatusPageAsync(CancellationToken can return HealthCheckResult.Unhealthy("Failed to parse StatusPage JSON.", jex); } } - - /// - /// Returns healthy for custom endpoints. - /// - private static Task CheckEndpointHealthAsync() - { - return Task.FromResult(HealthCheckResult.Healthy("Custom OpenAI endpoint configured")); - } }