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