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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 176 additions & 62 deletions sdk/cs/src/FoundryLocalManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +24,40 @@ namespace Microsoft.AI.Foundry.Local;
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// Exception thrown when the SDK cannot connect to the Foundry Local service.
/// </summary>
public class FoundryConnectionException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="FoundryConnectionException"/> class.
/// </summary>
public FoundryConnectionException()
: base("Could not connect to Foundry Local! Please check if the Foundry Local service is running and the host URL is correct.")
{
}

/// <summary>
/// Initializes a new instance of the <see cref="FoundryConnectionException"/> class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public FoundryConnectionException(string message)
: base(message)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="FoundryConnectionException"/> class with a specified error message
/// and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public FoundryConnectionException(string message, Exception innerException)
: base(message, innerException)
{
}
}

[JsonConverter(typeof(JsonStringEnumConverter<DeviceType>))]
public enum DeviceType
{
Expand Down Expand Up @@ -121,7 +157,7 @@ public async Task<List<ModelInfo>> 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 ?? [];
Expand Down Expand Up @@ -218,7 +254,7 @@ public void RefreshCatalog()
public async Task<string> 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()
Expand All @@ -228,7 +264,7 @@ public async Task<string> GetCacheLocationAsync(CancellationToken ct = default)
public async Task<List<ModelInfo>> 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<string[]>(jsonResponse) ?? [];
return await FetchModelInfosAsync(modelIds, ct);
Expand Down Expand Up @@ -258,7 +294,7 @@ public async Task<List<ModelInfo>> 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);

Expand Down Expand Up @@ -358,7 +394,7 @@ public async Task<ModelInfo> 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;
Expand Down Expand Up @@ -416,82 +452,108 @@ public async IAsyncEnumerable<ModelDownloadProgress> 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);
// 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
{
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<List<ModelInfo>> 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<string[]>(ct)
?? throw new InvalidOperationException("Failed to read loaded models.");
Expand All @@ -502,7 +564,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();
}
Expand Down Expand Up @@ -559,6 +621,58 @@ public static int GetVersion(string modelId)
return -1;
}

/// <summary>
/// Wraps an HTTP request operation and converts connection errors to a more user-friendly exception.
/// </summary>
/// <typeparam name="T">The return type of the operation.</typeparam>
/// <param name="operation">The async operation to execute.</param>
/// <returns>The result of the operation.</returns>
/// <exception cref="FoundryConnectionException">Thrown when the service is unreachable.</exception>
private static async Task<T> WrapHttpRequestAsync<T>(Func<Task<T>> 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);
}
}

/// <summary>
/// Wraps an HTTP request operation that doesn't return a value and converts connection errors to a more user-friendly exception.
/// </summary>
/// <param name="operation">The async operation to execute.</param>
/// <exception cref="FoundryConnectionException">Thrown when the service is unreachable.</exception>
private static async Task WrapHttpRequestAsync(Func<Task> 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<Uri?> EnsureServiceRunning(CancellationToken ct = default)
{
var startInfo = new ProcessStartInfo
Expand Down
25 changes: 25 additions & 0 deletions sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FoundryConnectionException>(() => _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<FoundryConnectionException>(() => _manager.ListCatalogModelsAsync());
Assert.Contains("Could not connect to Foundry Local", ex.Message);
}

[Fact]
public void Dispose_DisposesHttpClient()
{
Expand Down