| File: OpenAIHealthCheck.cs | Web Access |
| Project: src\src\Aspire.Hosting.OpenAI\Aspire.Hosting.OpenAI.csproj (Aspire.Hosting.OpenAI) |
// 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; /// <summary> /// Health check for OpenAI resources that adapts based on endpoint configuration. /// </summary> internal sealed class OpenAIHealthCheck : IHealthCheck { 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; private readonly TimeSpan _timeout; /// <summary> /// Initializes a new instance of the <see cref="OpenAIHealthCheck"/> class. /// </summary> /// <param name="httpClientFactory">The factory to create HTTP clients.</param> /// <param name="resource">The OpenAI resource.</param> /// <param name="httpClientName">The optional name of the HTTP client to use.</param> /// <param name="timeout">The optional timeout for the HTTP request.</param> 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<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { // Case 1: Default endpoint - check StatusPage if (Uri.TryCreate(_resource.Endpoint, UriKind.Absolute, out var endpointUri) && Uri.Compare(endpointUri, s_defaultEndpointUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) { return await CheckStatusPageAsync(cancellationToken).ConfigureAwait(false); } // Case 2: Custom endpoint - return healthy return HealthCheckResult.Healthy("Custom OpenAI endpoint configured"); } /// <summary> /// Checks the StatusPage endpoint for the default OpenAI service. /// </summary> private async Task<HealthCheckResult> CheckStatusPageAsync(CancellationToken cancellationToken) { 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, s_statusPageUri); 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<string, object> { ["indicator"] = indicator, ["description"] = description, ["endpoint"] = s_statusPageUri.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); } } }