From b27a0a509c003688bae5bed8e501498e92cc3607 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 23:53:09 +0000 Subject: [PATCH 1/3] Initial plan From dcf86265e65644cfa8569280d584750d3f3cce4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:08:39 +0000 Subject: [PATCH 2/3] Add FoundryConnectionException to C# SDK for better offline error handling Co-authored-by: natke <3302433+natke@users.noreply.github.com> --- sdk/cs/src/FoundryLocalManager.cs | 235 +++++++++++++----- .../FoundryLocalManagerTest.cs | 25 ++ 2 files changed, 198 insertions(+), 62 deletions(-) diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index 737c965..4de4dea 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -11,8 +11,10 @@ namespace Microsoft.AI.Foundry.Local; using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Net.Http; using System.Net.Http.Json; using System.Net.Mime; +using System.Net.Sockets; using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -22,6 +24,40 @@ namespace Microsoft.AI.Foundry.Local; using System.Threading; using System.Threading.Tasks; +/// +/// Exception thrown when the SDK cannot connect to the Foundry Local service. +/// +public class FoundryConnectionException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public FoundryConnectionException() + : base("Could not connect to Foundry Local! Please check if the Foundry Local service is running and the host URL is correct.") + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public FoundryConnectionException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public FoundryConnectionException(string message, Exception innerException) + : base(message, innerException) + { + } +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum DeviceType { @@ -121,7 +157,7 @@ public async Task> ListCatalogModelsAsync(CancellationToken ct = if (_catalogModels == null) { await StartServiceAsync(ct); - var results = await _serviceClient!.GetAsync("/foundry/list", ct); + var results = await WrapHttpRequestAsync(async () => await _serviceClient!.GetAsync("/foundry/list", ct)); var jsonResponse = await results.Content.ReadAsStringAsync(ct); var models = JsonSerializer.Deserialize(jsonResponse, ModelGenerationContext.Default.ListModelInfo); _catalogModels = models ?? []; @@ -218,7 +254,7 @@ public void RefreshCatalog() public async Task GetCacheLocationAsync(CancellationToken ct = default) { await StartServiceAsync(ct); - var response = await _serviceClient!.GetAsync("/openai/status", ct); + var response = await WrapHttpRequestAsync(async () => await _serviceClient!.GetAsync("/openai/status", ct)); var json = await response.Content.ReadAsStringAsync(ct); var jsonDocument = JsonDocument.Parse(json); return jsonDocument.RootElement.GetProperty("modelDirPath").GetString() @@ -228,7 +264,7 @@ public async Task GetCacheLocationAsync(CancellationToken ct = default) public async Task> ListCachedModelsAsync(CancellationToken ct = default) { await StartServiceAsync(ct); - var results = await _serviceClient!.GetAsync("/openai/models", ct); + var results = await WrapHttpRequestAsync(async () => await _serviceClient!.GetAsync("/openai/models", ct)); var jsonResponse = await results.Content.ReadAsStringAsync(ct); var modelIds = JsonSerializer.Deserialize(jsonResponse) ?? []; return await FetchModelInfosAsync(modelIds, ct); @@ -258,7 +294,7 @@ public async Task> ListCachedModelsAsync(CancellationToken ct = IgnorePipeReport = true }; - var response = await _serviceClient!.PostAsJsonAsync("/openai/download", request, ct); + var response = await WrapHttpRequestAsync(async () => await _serviceClient!.PostAsJsonAsync("/openai/download", request, ct)); response.EnsureSuccessStatusCode(); var responseBody = await response.Content.ReadAsStringAsync(ct); @@ -358,7 +394,7 @@ public async Task LoadModelAsync(string aliasOrModelId, DeviceType? d Query = string.Join("&", queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")) }; - var response = await _serviceClient!.GetAsync(uriBuilder.Uri, ct); + var response = await WrapHttpRequestAsync(async () => await _serviceClient!.GetAsync(uriBuilder.Uri, ct)); response.EnsureSuccessStatusCode(); return modelInfo; @@ -416,82 +452,105 @@ public async IAsyncEnumerable DownloadModelWithProgressAs Encoding.UTF8, MediaTypeNames.Application.Json); - using var response = await _serviceClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - response.EnsureSuccessStatusCode(); - - using var stream = await response.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); + HttpResponseMessage response; + string? connectionError = null; + try + { + response = await _serviceClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException ex) when (ex.InnerException is SocketException) + { + connectionError = "Could not connect to Foundry Local! Please check if the Foundry Local service is running and the host URL is correct."; + response = null!; + } + catch (HttpRequestException ex) + { + connectionError = $"Could not connect to Foundry Local! {ex.Message}"; + response = null!; + } - string? line; - var completed = false; - StringBuilder jsonBuilder = new(); - var collectingJson = false; + if (connectionError != null) + { + yield return ModelDownloadProgress.Error(connectionError); + yield break; + } - while (!completed && (line = await reader.ReadLineAsync(ct)) is not null) + using (response) + using (var stream = await response.Content.ReadAsStreamAsync(ct)) + using (var reader = new StreamReader(stream)) { - // Check if this line contains download percentage - if (line.StartsWith("Total", StringComparison.CurrentCultureIgnoreCase) && line.Contains("Downloading") && line.Contains('%')) + string? line; + var completed = false; + StringBuilder jsonBuilder = new(); + var collectingJson = false; + + while (!completed && (line = await reader.ReadLineAsync(ct)) is not null) { - // Parse percentage from line like "Total 45.67% Downloading model.onnx.data" - var percentStr = line.Split('%')[0].Split(' ').Last(); - if (double.TryParse(percentStr, out var percentage)) + // Check if this line contains download percentage + if (line.StartsWith("Total", StringComparison.CurrentCultureIgnoreCase) && line.Contains("Downloading") && line.Contains('%')) + { + // Parse percentage from line like "Total 45.67% Downloading model.onnx.data" + var percentStr = line.Split('%')[0].Split(' ').Last(); + if (double.TryParse(percentStr, out var percentage)) + { + yield return ModelDownloadProgress.Progress(percentage); + } + } + else if (line.Contains("[DONE]") || line.Contains("All Completed")) { - yield return ModelDownloadProgress.Progress(percentage); + // Start collecting JSON after we see the completion marker + collectingJson = true; + } + else if (collectingJson && line.Trim().StartsWith("{", StringComparison.CurrentCultureIgnoreCase)) + { + // Start of JSON object + jsonBuilder.AppendLine(line); + } + else if (collectingJson && jsonBuilder.Length > 0) + { + // Continue collecting JSON + jsonBuilder.AppendLine(line); + + // Check if we have a complete JSON object by looking for ending brace + if (line.Trim() == "}") + { + completed = true; + } } } - else if (line.Contains("[DONE]") || line.Contains("All Completed")) - { - // Start collecting JSON after we see the completion marker - collectingJson = true; - } - else if (collectingJson && line.Trim().StartsWith("{", StringComparison.CurrentCultureIgnoreCase)) - { - // Start of JSON object - jsonBuilder.AppendLine(line); - } - else if (collectingJson && jsonBuilder.Length > 0) + if (jsonBuilder.Length > 0) { - // Continue collecting JSON - jsonBuilder.AppendLine(line); + var jsonPart = jsonBuilder.ToString(); + ModelDownloadProgress result; - // Check if we have a complete JSON object by looking for ending brace - if (line.Trim() == "}") + try { - completed = true; - } - } - } - if (jsonBuilder.Length > 0) - { - var jsonPart = jsonBuilder.ToString(); - ModelDownloadProgress result; + using var jsonDoc = JsonDocument.Parse(jsonPart); + var success = jsonDoc.RootElement.GetProperty("success").GetBoolean(); + var errorMessage = jsonDoc.RootElement.GetProperty("errorMessage").GetString(); - try - { - using var jsonDoc = JsonDocument.Parse(jsonPart); - var success = jsonDoc.RootElement.GetProperty("success").GetBoolean(); - var errorMessage = jsonDoc.RootElement.GetProperty("errorMessage").GetString(); + result = success + ? ModelDownloadProgress.Completed(modelInfo) + : ModelDownloadProgress.Error(errorMessage ?? "Unknown error"); + } + catch (JsonException ex) + { + result = ModelDownloadProgress.Error($"Failed to parse JSON response: {ex.Message}"); + } - result = success - ? ModelDownloadProgress.Completed(modelInfo) - : ModelDownloadProgress.Error(errorMessage ?? "Unknown error"); + yield return result; } - catch (JsonException ex) + else { - result = ModelDownloadProgress.Error($"Failed to parse JSON response: {ex.Message}"); + yield return ModelDownloadProgress.Error("No completion response received"); } - - yield return result; - } - else - { - yield return ModelDownloadProgress.Error("No completion response received"); } } public async Task> ListLoadedModelsAsync(CancellationToken ct = default) { - var response = await _serviceClient!.GetAsync(new Uri(ServiceUri, "/openai/loadedmodels"), ct); + var response = await WrapHttpRequestAsync(async () => await _serviceClient!.GetAsync(new Uri(ServiceUri, "/openai/loadedmodels"), ct)); response.EnsureSuccessStatusCode(); var names = await response.Content.ReadFromJsonAsync(ct) ?? throw new InvalidOperationException("Failed to read loaded models."); @@ -502,7 +561,7 @@ public async Task UnloadModelAsync(string aliasOrModelId, DeviceType? device = n { var modelInfo = await GetModelInfoAsync(aliasOrModelId, device, ct) ?? throw new InvalidOperationException($"Model {aliasOrModelId} not found in catalog."); - var response = await _serviceClient!.GetAsync($"/openai/unload/{modelInfo.ModelId}?force={force.ToString().ToLowerInvariant()}", ct); + var response = await WrapHttpRequestAsync(async () => await _serviceClient!.GetAsync($"/openai/unload/{modelInfo.ModelId}?force={force.ToString().ToLowerInvariant()}", ct)); response.EnsureSuccessStatusCode(); } @@ -559,6 +618,58 @@ public static int GetVersion(string modelId) return -1; } + /// + /// Wraps an HTTP request operation and converts connection errors to a more user-friendly exception. + /// + /// The return type of the operation. + /// The async operation to execute. + /// The result of the operation. + /// Thrown when the service is unreachable. + private static async Task WrapHttpRequestAsync(Func> operation) + { + try + { + return await operation(); + } + catch (HttpRequestException ex) when (ex.InnerException is SocketException) + { + throw new FoundryConnectionException( + "Could not connect to Foundry Local! Please check if the Foundry Local service is running and the host URL is correct.", + ex); + } + catch (HttpRequestException ex) + { + throw new FoundryConnectionException( + $"Could not connect to Foundry Local! {ex.Message}", + ex); + } + } + + /// + /// Wraps an HTTP request operation that doesn't return a value and converts connection errors to a more user-friendly exception. + /// + /// The async operation to execute. + /// Thrown when the service is unreachable. + private static async Task WrapHttpRequestAsync(Func operation) + { + try + { + await operation(); + } + catch (HttpRequestException ex) when (ex.InnerException is SocketException) + { + throw new FoundryConnectionException( + "Could not connect to Foundry Local! Please check if the Foundry Local service is running and the host URL is correct.", + ex); + } + catch (HttpRequestException ex) + { + throw new FoundryConnectionException( + $"Could not connect to Foundry Local! {ex.Message}", + ex); + } + } + private static async Task EnsureServiceRunning(CancellationToken ct = default) { var startInfo = new ProcessStartInfo diff --git a/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs b/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs index f8f4003..7bc99be 100644 --- a/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs +++ b/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs @@ -10,6 +10,7 @@ namespace Microsoft.AI.Foundry.Local.Tests; using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Net.Sockets; using System.Reflection; using System.Text; using System.Text.Json; @@ -614,6 +615,30 @@ public async Task UpgradeModelAsync_ThrowsWhenModelNotFound() Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task ListCatalogModelsAsync_ThrowsFoundryConnectionException_WhenConnectionFails() + { + // GIVEN - Set up a mock that simulates a connection failure + _mockHttp.When(HttpMethod.Get, "/foundry/list") + .Throw(new HttpRequestException("Connection refused", new SocketException(10061))); + + // WHEN/THEN + var ex = await Assert.ThrowsAsync(() => _manager.ListCatalogModelsAsync()); + Assert.Contains("Could not connect to Foundry Local", ex.Message); + } + + [Fact] + public async Task ListCatalogModelsAsync_ThrowsFoundryConnectionException_WhenHttpRequestFails() + { + // GIVEN - Set up a mock that simulates an HTTP request failure + _mockHttp.When(HttpMethod.Get, "/foundry/list") + .Throw(new HttpRequestException("Name or service not known")); + + // WHEN/THEN + var ex = await Assert.ThrowsAsync(() => _manager.ListCatalogModelsAsync()); + Assert.Contains("Could not connect to Foundry Local", ex.Message); + } + [Fact] public void Dispose_DisposesHttpClient() { From 9729362e2e1221cbeb6ef6dba5d2f6bbc1d8f444 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:13:48 +0000 Subject: [PATCH 3/3] Add comment explaining inline exception handling in DownloadModelWithProgressAsync Co-authored-by: natke <3302433+natke@users.noreply.github.com> --- sdk/cs/src/FoundryLocalManager.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index 4de4dea..3e24363 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -452,6 +452,9 @@ public async IAsyncEnumerable DownloadModelWithProgressAs Encoding.UTF8, MediaTypeNames.Application.Json); + // Note: We handle connection errors inline here instead of using WrapHttpRequestAsync + // because this is an async iterator method that needs to yield error results rather + // than throw exceptions. The WrapHttpRequestAsync pattern doesn't work with yield return. HttpResponseMessage response; string? connectionError = null; try