diff --git a/AIDevGallery.Tests/AIDevGallery.Tests.csproj b/AIDevGallery.Tests/AIDevGallery.Tests.csproj
index 984a9bdb..c8fcde0e 100644
--- a/AIDevGallery.Tests/AIDevGallery.Tests.csproj
+++ b/AIDevGallery.Tests/AIDevGallery.Tests.csproj
@@ -12,7 +12,7 @@
true
enable
false
- CS1591
+ CS1591;IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025
Recommended
false
diff --git a/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalChatClientAdapterTests.cs b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalChatClientAdapterTests.cs
new file mode 100644
index 00000000..7b116299
--- /dev/null
+++ b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalChatClientAdapterTests.cs
@@ -0,0 +1,219 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.AI;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+
+namespace AIDevGallery.Tests.UnitTests;
+
+///
+/// Tests for FoundryLocalChatClientAdapter focusing on pure functions and data transformations.
+/// Note: Integration tests requiring actual FoundryLocal SDK initialization are excluded.
+///
+[TestClass]
+public class FoundryLocalChatClientAdapterTests
+{
+ [TestMethod]
+ public void ConvertToOpenAIMessagesConvertsMultipleMessagesWithDifferentRoles()
+ {
+ // Arrange
+ var messages = new List
+ {
+ new(ChatRole.System, "You are a helpful assistant."),
+ new(ChatRole.User, "What is the weather?"),
+ new(ChatRole.Assistant, "I don't have access to real-time weather data.")
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(3, result.Count);
+ Assert.AreEqual("system", result[0].Role);
+ Assert.AreEqual("You are a helpful assistant.", result[0].Content);
+ Assert.AreEqual("user", result[1].Role);
+ Assert.AreEqual("What is the weather?", result[1].Content);
+ Assert.AreEqual("assistant", result[2].Role);
+ Assert.AreEqual("I don't have access to real-time weather data.", result[2].Content);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesHandlesNullTextAsEmptyString()
+ {
+ // Arrange - Critical: null content should be converted to empty string, not cause NullReferenceException
+ var messages = new List
+ {
+ new(ChatRole.User, (string?)null)
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual("user", result[0].Role);
+ Assert.AreEqual(string.Empty, result[0].Content);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesHandlesEmptyList()
+ {
+ // Arrange
+ var messages = new List();
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesHandlesCustomRoles()
+ {
+ // Arrange - Important: custom roles like "tool" should be preserved
+ var messages = new List
+ {
+ new(new ChatRole("tool"), "Tool output")
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual("tool", result[0].Role);
+ Assert.AreEqual("Tool output", result[0].Content);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesPreservesMessageOrder()
+ {
+ // Arrange - Critical: message order must be preserved for proper conversation flow
+ var messages = new List
+ {
+ new(ChatRole.System, "System message"),
+ new(ChatRole.User, "First user message"),
+ new(ChatRole.Assistant, "First assistant response"),
+ new(ChatRole.User, "Second user message"),
+ new(ChatRole.Assistant, "Second assistant response")
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(5, result.Count);
+ Assert.AreEqual("system", result[0].Role);
+ Assert.AreEqual("System message", result[0].Content);
+ Assert.AreEqual("user", result[1].Role);
+ Assert.AreEqual("First user message", result[1].Content);
+ Assert.AreEqual("assistant", result[2].Role);
+ Assert.AreEqual("First assistant response", result[2].Content);
+ Assert.AreEqual("user", result[3].Role);
+ Assert.AreEqual("Second user message", result[3].Content);
+ Assert.AreEqual("assistant", result[4].Role);
+ Assert.AreEqual("Second assistant response", result[4].Content);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesOnlyTextContentIgnoresMultiModal()
+ {
+ // NOTE: This test documents current limitation - multi-modal content is not supported
+ // The current implementation only handles text content via ChatMessage.Text property
+ // Future enhancement may add image/audio support
+
+ // Arrange - Message with only text content
+ var messages = new List
+ {
+ new(ChatRole.User, "Text only message")
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual("Text only message", result[0].Content);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesMultipleConsecutiveSameRoleAllowed()
+ {
+ // Arrange - Some models allow multiple messages from same role
+ var messages = new List
+ {
+ new(ChatRole.User, "First question"),
+ new(ChatRole.User, "Second question"),
+ new(ChatRole.Assistant, "Combined answer")
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(3, result.Count);
+ Assert.AreEqual("user", result[0].Role);
+ Assert.AreEqual("user", result[1].Role);
+ Assert.AreEqual("assistant", result[2].Role);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesVeryLongMessageIsPreserved()
+ {
+ // Arrange - Test with a very long message
+ var longContent = new string('A', 10000); // 10K characters
+ var messages = new List
+ {
+ new(ChatRole.User, longContent)
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual(longContent, result[0].Content);
+ Assert.AreEqual(10000, result[0].Content!.Length);
+ }
+
+ [TestMethod]
+ public void ConvertToOpenAIMessagesSpecialCharactersArePreserved()
+ {
+ // Arrange - Test with special characters that might need escaping
+ var specialContent = "Hello\nWorld\tWith\"Quotes\" and 'apostrophes' & symbols <>";
+ var messages = new List
+ {
+ new(ChatRole.User, specialContent)
+ };
+
+ // Act
+ var result = InvokeConvertToOpenAIMessages(messages);
+
+ // Assert
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual(specialContent, result[0].Content);
+ }
+
+ ///
+ /// Uses reflection to invoke the private static ConvertToOpenAIMessages method.
+ ///
+ private static List InvokeConvertToOpenAIMessages(
+ IEnumerable messages)
+ {
+ var adapterType = Type.GetType("AIDevGallery.ExternalModelUtils.FoundryLocal.FoundryLocalChatClientAdapter, AIDevGallery");
+ Assert.IsNotNull(adapterType, "FoundryLocalChatClientAdapter type not found");
+
+ var method = adapterType.GetMethod(
+ "ConvertToOpenAIMessages",
+ BindingFlags.NonPublic | BindingFlags.Static);
+
+ Assert.IsNotNull(method, "ConvertToOpenAIMessages method not found");
+
+ var result = method.Invoke(null, new object[] { messages });
+ return (List)result!;
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalDataModelsTests.cs b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalDataModelsTests.cs
new file mode 100644
index 00000000..b855d10b
--- /dev/null
+++ b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalDataModelsTests.cs
@@ -0,0 +1,150 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using AIDevGallery.ExternalModelUtils.FoundryLocal;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace AIDevGallery.Tests.UnitTests;
+
+///
+/// Tests for FoundryLocal data models and utility types.
+///
+public class FoundryLocalDataModelsTests
+{
+ [TestClass]
+ public class FoundryCatalogModelTests
+ {
+ [TestMethod]
+ public void FoundryCatalogModelAllPropertiesCanBeSetAndRetrieved()
+ {
+ // Arrange & Act
+ var model = new FoundryCatalogModel
+ {
+ Name = "phi-3.5-mini-instruct",
+ DisplayName = "Phi-3.5 Mini Instruct",
+ Alias = "phi-3.5-mini",
+ FileSizeMb = 2500,
+ License = "MIT",
+ ModelId = "microsoft/phi-3.5-mini",
+ Task = "chat-completion"
+ };
+
+ // Assert
+ Assert.AreEqual("phi-3.5-mini-instruct", model.Name);
+ Assert.AreEqual("Phi-3.5 Mini Instruct", model.DisplayName);
+ Assert.AreEqual("phi-3.5-mini", model.Alias);
+ Assert.AreEqual(2500, model.FileSizeMb);
+ Assert.AreEqual("MIT", model.License);
+ Assert.AreEqual("microsoft/phi-3.5-mini", model.ModelId);
+ Assert.AreEqual("chat-completion", model.Task);
+ }
+
+ [TestMethod]
+ public void FoundryCatalogModelDefaultValuesAreZeroOrNull()
+ {
+ // Arrange & Act
+ var model = new FoundryCatalogModel();
+
+ // Assert
+ Assert.AreEqual(0, model.FileSizeMb);
+ Assert.IsTrue(string.IsNullOrEmpty(model.Name));
+ Assert.IsTrue(string.IsNullOrEmpty(model.DisplayName));
+ Assert.IsTrue(string.IsNullOrEmpty(model.Alias));
+ }
+ }
+
+ [TestClass]
+ public class FoundryCachedModelInfoTests
+ {
+ [TestMethod]
+ public void FoundryCachedModelInfoConstructorSetsProperties()
+ {
+ // Arrange & Act
+ var modelInfo = new FoundryCachedModelInfo("phi-3.5-mini-instruct", "phi-3.5-mini");
+
+ // Assert
+ Assert.AreEqual("phi-3.5-mini-instruct", modelInfo.Name);
+ Assert.AreEqual("phi-3.5-mini", modelInfo.Id);
+ }
+
+ [TestMethod]
+ public void FoundryCachedModelInfoIsRecordSupportsValueEquality()
+ {
+ // Arrange
+ var info1 = new FoundryCachedModelInfo("test-model", "test-id");
+ var info2 = new FoundryCachedModelInfo("test-model", "test-id");
+ var info3 = new FoundryCachedModelInfo("different-model", "different-id");
+
+ // Assert - Record types support value-based equality
+ Assert.AreEqual(info1, info2, "Same values should be equal");
+ Assert.AreNotEqual(info1, info3, "Different values should not be equal");
+ }
+ }
+
+ [TestClass]
+ public class FoundryDownloadResultTests
+ {
+ [TestMethod]
+ public void FoundryDownloadResultSuccessfulDownloadHasNoErrorMessage()
+ {
+ // Arrange & Act
+ var result = new FoundryDownloadResult(true, null);
+
+ // Assert
+ Assert.IsTrue(result.Success);
+ Assert.IsNull(result.ErrorMessage);
+ }
+
+ [TestMethod]
+ public void FoundryDownloadResultFailedDownloadHasErrorMessage()
+ {
+ // Arrange
+ var errorMsg = "Network timeout";
+
+ // Act
+ var result = new FoundryDownloadResult(false, errorMsg);
+
+ // Assert
+ Assert.IsFalse(result.Success);
+ Assert.AreEqual(errorMsg, result.ErrorMessage);
+ }
+
+ [TestMethod]
+ public void FoundryDownloadResultSuccessWithWarningBothSuccessAndMessage()
+ {
+ // Arrange - Important: download can succeed but have warnings
+ var warningMsg = "Model loaded but some features unavailable";
+
+ // Act
+ var result = new FoundryDownloadResult(true, warningMsg);
+
+ // Assert
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(warningMsg, result.ErrorMessage);
+ }
+ }
+
+ [TestClass]
+ public class ModelTaskTypesTests
+ {
+ [TestMethod]
+ public void ModelTaskTypesChatCompletionHasCorrectValue()
+ {
+ // Arrange & Act
+ var chatCompletion = ModelTaskTypes.ChatCompletion;
+
+ // Assert
+ Assert.AreEqual("chat-completion", chatCompletion);
+ }
+
+ [TestMethod]
+ public void ModelTaskTypesAutomaticSpeechRecognitionHasCorrectValue()
+ {
+ // Arrange & Act
+ var asr = ModelTaskTypes.AutomaticSpeechRecognition;
+
+ // Assert
+ Assert.AreEqual("automatic-speech-recognition", asr);
+ }
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalModelProviderTests.cs b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalModelProviderTests.cs
new file mode 100644
index 00000000..ce4ad1a4
--- /dev/null
+++ b/AIDevGallery.Tests/UnitTests/ExternalModelUtils/FoundryLocalModelProviderTests.cs
@@ -0,0 +1,226 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using AIDevGallery.ExternalModelUtils;
+using AIDevGallery.Models;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Threading.Tasks;
+
+namespace AIDevGallery.Tests.UnitTests;
+
+[TestClass]
+public class FoundryLocalModelProviderTests
+{
+ // Basic properties tests - Consolidated
+ [TestMethod]
+ public void BasicPropertiesReturnExpectedValues()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+
+ // Assert - Test all basic properties together
+ Assert.AreEqual("FoundryLocal", provider.Name);
+ Assert.AreEqual(HardwareAccelerator.FOUNDRYLOCAL, provider.ModelHardwareAccelerator);
+ Assert.AreEqual("fl://", provider.UrlPrefix);
+ Assert.AreEqual(string.Empty, provider.Url, "Foundry Local uses direct SDK calls, not web service");
+ Assert.AreEqual("The model will run locally via Foundry Local", provider.ProviderDescription);
+ Assert.AreEqual("Microsoft.AI.Foundry.Local", provider.IChatClientImplementationNamespace);
+ }
+
+ [TestMethod]
+ public void NugetPackageReferencesContainsRequiredPackages()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+
+ // Act
+ var packages = provider.NugetPackageReferences;
+
+ // Assert
+ Assert.IsNotNull(packages);
+ Assert.AreEqual(2, packages.Count, "Should contain exactly 2 packages");
+ Assert.IsTrue(packages.Contains("Microsoft.AI.Foundry.Local.WinML"));
+ Assert.IsTrue(packages.Contains("Microsoft.Extensions.AI"));
+ }
+
+ [TestMethod]
+ public void GetDetailsUrlReturnsNull()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+ var modelDetails = new ModelDetails { Name = "test-model" };
+
+ // Act
+ var url = provider.GetDetailsUrl(modelDetails);
+
+ // Assert
+ Assert.IsNull(url, "Foundry Local models run locally, so no online details page should be available");
+ }
+
+ [TestMethod]
+ public void InstanceIsSingleton()
+ {
+ // Arrange & Act
+ var instance1 = FoundryLocalModelProvider.Instance;
+ var instance2 = FoundryLocalModelProvider.Instance;
+
+ // Assert
+ Assert.AreSame(instance1, instance2, "Instance should be a singleton");
+ }
+
+ // Edge case: Invalid model type
+ [TestMethod]
+ public async Task DownloadModelWithNonFoundryCatalogModelReturnsFalse()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+ var modelDetails = new ModelDetails
+ {
+ Name = "test-model",
+ Url = "https://example.com/model",
+ ProviderModelDetails = "Not a FoundryCatalogModel" // Wrong type
+ };
+
+ // Act
+ var result = await provider.DownloadModel(modelDetails, null, default);
+
+ // Assert
+ Assert.IsFalse(result.Success, "Should fail when ProviderModelDetails is wrong type");
+
+ // In unit test environment, manager might not be initialized, both error messages are valid
+ Assert.IsTrue(
+ result.ErrorMessage == "Invalid model details" ||
+ result.ErrorMessage == "Foundry Local manager not initialized",
+ $"Expected error about invalid model or uninitialized manager, but got: {result.ErrorMessage}");
+ }
+
+ [TestMethod]
+ public async Task DownloadModelWithNullProviderModelDetailsReturnsFalse()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+ var modelDetails = new ModelDetails
+ {
+ Name = "test-model",
+ Url = "https://example.com/model",
+ ProviderModelDetails = null // Null provider details
+ };
+
+ // Act
+ var result = await provider.DownloadModel(modelDetails, null, default);
+
+ // Assert
+ Assert.IsFalse(result.Success, "Should fail when ProviderModelDetails is null");
+
+ // In unit test environment, manager might not be initialized, both error messages are valid
+ Assert.IsTrue(
+ result.ErrorMessage == "Invalid model details" ||
+ result.ErrorMessage == "Foundry Local manager not initialized",
+ $"Expected error about invalid model or uninitialized manager, but got: {result.ErrorMessage}");
+ }
+
+ // Test initialization behavior
+ [TestMethod]
+ public async Task GetModelsAsyncWithIgnoreCachedTrueResetsDownloadedModels()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+
+ // Act - Call with ignoreCached=true
+ var models1 = await provider.GetModelsAsync(ignoreCached: true);
+ var models2 = await provider.GetModelsAsync(ignoreCached: false);
+
+ // Assert - Both should return collections (empty or populated)
+ Assert.IsNotNull(models1);
+ Assert.IsNotNull(models2);
+ }
+
+ [TestMethod]
+ public void GetAllModelsInCatalogReturnsEmptyCollectionBeforeInitialization()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+
+ // Act
+ var catalogModels = provider.GetAllModelsInCatalog();
+
+ // Assert - Should return empty collection if not initialized, not null
+ Assert.IsNotNull(catalogModels);
+ }
+
+ [TestMethod]
+ public void ExtractAliasRemovesUrlPrefix()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+ var testCases = new[]
+ {
+ ("fl://phi-3.5-mini-instruct", "phi-3.5-mini-instruct"),
+ ("fl://mistral-7b", "mistral-7b"),
+ ("fl://model-name", "model-name")
+ };
+
+ // Act & Assert
+ foreach (var (input, expected) in testCases)
+ {
+ // Use reflection to call private ExtractAlias method
+ var method = typeof(FoundryLocalModelProvider).GetMethod("ExtractAlias", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ Assert.IsNotNull(method, "ExtractAlias method should exist");
+
+ var result = method.Invoke(provider, new object[] { input }) as string;
+ Assert.AreEqual(expected, result, $"Failed to extract alias from {input}");
+ }
+ }
+
+ // Note: Tests for GetRequiredTasksForModelTypes are omitted because ModelType is generated by source generator
+ // and not directly accessible in unit tests. These methods are tested through integration tests.
+ [TestMethod]
+ public void GetIChatClientStringWithValidAliasReturnsCodeSnippet()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+ var testUrl = "fl://test-model";
+
+ // Act
+ var result = provider.GetIChatClientString(testUrl);
+
+ // Assert
+ // Before initialization, should return null
+ // The code snippet generation requires a loaded model
+ Assert.IsTrue(
+ result == null || result.Contains("FoundryLocalManager"),
+ "Should return null before initialization or contain FoundryLocalManager code");
+ }
+
+ [TestMethod]
+ public void IChatClientImplementationNamespaceReturnsCorrectNamespace()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+
+ // Act
+ var ns = provider.IChatClientImplementationNamespace;
+
+ // Assert
+ Assert.AreEqual("Microsoft.AI.Foundry.Local", ns, "Should return the correct Foundry Local SDK namespace");
+ }
+
+ // Note: Icon property test is omitted because it depends on AppUtils.GetThemeAssetSuffix()
+ // which requires a running WinUI application context (not available in unit tests)
+ [TestMethod]
+ public async Task GetModelsAsyncCalledMultipleTimesDoesNotReinitialize()
+ {
+ // Arrange
+ var provider = FoundryLocalModelProvider.Instance;
+
+ // Act
+ var models1 = await provider.GetModelsAsync(ignoreCached: false);
+ var models2 = await provider.GetModelsAsync(ignoreCached: false);
+
+ // Assert
+ Assert.IsNotNull(models1);
+ Assert.IsNotNull(models2);
+
+ // Both calls should succeed and return consistent results
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery.Tests/UnitTests/Models/CachedModelTests.cs b/AIDevGallery.Tests/UnitTests/Models/CachedModelTests.cs
new file mode 100644
index 00000000..68a47ddc
--- /dev/null
+++ b/AIDevGallery.Tests/UnitTests/Models/CachedModelTests.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using AIDevGallery.Models;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+
+namespace AIDevGallery.Tests.UnitTests;
+
+[TestClass]
+public class CachedModelTests
+{
+ [TestMethod]
+ public void CachedModelWithFoundryLocalUrlSetsSourceToFoundryLocal()
+ {
+ // Arrange
+ var modelDetails = new ModelDetails
+ {
+ Name = "Phi-3.5",
+ Url = "fl://phi-3.5-mini"
+ };
+
+ // Act
+ var cachedModel = new CachedModel(modelDetails, "/path/to/model", false, 1024);
+
+ // Assert
+ Assert.AreEqual(CachedModelSource.FoundryLocal, cachedModel.Source);
+ Assert.AreEqual("fl://phi-3.5-mini", cachedModel.Url);
+ }
+
+ [TestMethod]
+ public void CachedModelWithGitHubUrlSetsSourceToGitHub()
+ {
+ // Arrange
+ var modelDetails = new ModelDetails
+ {
+ Name = "Test Model",
+ Url = "https://github.com/user/repo/model.onnx"
+ };
+
+ // Act
+ var cachedModel = new CachedModel(modelDetails, "/path/to/model", true, 2048);
+
+ // Assert
+ Assert.AreEqual(CachedModelSource.GitHub, cachedModel.Source);
+ }
+
+ [TestMethod]
+ public void CachedModelWithLocalUrlSetsSourceToLocal()
+ {
+ // Arrange
+ var modelDetails = new ModelDetails
+ {
+ Name = "Local Model",
+ Url = "local://C:/models/model.onnx"
+ };
+
+ // Act
+ var cachedModel = new CachedModel(modelDetails, "C:/models/model.onnx", true, 512);
+
+ // Assert
+ Assert.AreEqual(CachedModelSource.Local, cachedModel.Source);
+ }
+
+ [TestMethod]
+ public void CachedModelWithHuggingFaceUrlSetsSourceToHuggingFace()
+ {
+ // Arrange
+ var modelDetails = new ModelDetails
+ {
+ Name = "HF Model",
+ Url = "microsoft/phi-2"
+ };
+
+ // Act
+ var cachedModel = new CachedModel(modelDetails, "/path/to/model", false, 4096);
+
+ // Assert
+ Assert.AreEqual(CachedModelSource.HuggingFace, cachedModel.Source);
+ }
+
+ [TestMethod]
+ public void CachedModelConstructorSetsAllProperties()
+ {
+ // Arrange
+ var modelDetails = new ModelDetails
+ {
+ Name = "Test Model",
+ Url = "fl://test-model"
+ };
+ var path = "/test/path";
+ var isFile = true;
+ var modelSize = 12345L;
+
+ // Act
+ var cachedModel = new CachedModel(modelDetails, path, isFile, modelSize);
+
+ // Assert
+ Assert.AreEqual(modelDetails, cachedModel.Details);
+ Assert.AreEqual(path, cachedModel.Path);
+ Assert.AreEqual(isFile, cachedModel.IsFile);
+ Assert.AreEqual(modelSize, cachedModel.ModelSize);
+ Assert.IsTrue(cachedModel.DateTimeCached <= DateTime.Now);
+ Assert.IsTrue(cachedModel.DateTimeCached > DateTime.Now.AddSeconds(-5));
+ }
+
+ [TestMethod]
+ public void CachedModelFoundryLocalUrlCaseInsensitive()
+ {
+ // Arrange - Test case insensitivity
+ var testUrls = new[] { "fl://model", "FL://model", "Fl://model" };
+
+ foreach (var url in testUrls)
+ {
+ var modelDetails = new ModelDetails
+ {
+ Name = "Test",
+ Url = url
+ };
+
+ // Act
+ var cachedModel = new CachedModel(modelDetails, "/path", false, 100);
+
+ // Assert
+ Assert.AreEqual(
+ CachedModelSource.FoundryLocal,
+ cachedModel.Source,
+ $"URL '{url}' should be recognized as FoundryLocal");
+ }
+ }
+
+ [TestMethod]
+ public void CachedModelSourceFoundryLocalEnumExists()
+ {
+ // Verify the new enum value exists
+ var foundryLocalValue = CachedModelSource.FoundryLocal;
+
+ Assert.IsTrue(Enum.IsDefined(foundryLocalValue));
+ Assert.AreEqual("FoundryLocal", foundryLocalValue.ToString());
+ }
+
+ [TestMethod]
+ public void CachedModelSourceAllValuesAreDefined()
+ {
+ // Ensure all expected sources are defined
+ var expectedSources = new[]
+ {
+ CachedModelSource.GitHub,
+ CachedModelSource.HuggingFace,
+ CachedModelSource.Local,
+ CachedModelSource.FoundryLocal
+ };
+
+ foreach (var source in expectedSources)
+ {
+ Assert.IsTrue(
+ Enum.IsDefined(source),
+ $"CachedModelSource.{source} should be defined");
+ }
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery.Tests/UnitTests/Utils/ModelDownloadTests.cs b/AIDevGallery.Tests/UnitTests/Utils/ModelDownloadTests.cs
new file mode 100644
index 00000000..778d45f3
--- /dev/null
+++ b/AIDevGallery.Tests/UnitTests/Utils/ModelDownloadTests.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using AIDevGallery.Models;
+using AIDevGallery.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Reflection;
+
+namespace AIDevGallery.Tests.UnitTests;
+
+[TestClass]
+public class ModelDownloadTests
+{
+ [TestMethod]
+ public void ModelDownloadEventArgsWithWarningMessageStoresWarning()
+ {
+ // Arrange
+ var warningMessage = "Model loaded but with minor issues";
+ var eventArgs = new ModelDownloadEventArgs
+ {
+ Progress = 1.0f,
+ Status = DownloadStatus.Completed,
+ WarningMessage = warningMessage
+ };
+
+ // Act & Assert
+ Assert.AreEqual(warningMessage, eventArgs.WarningMessage);
+ Assert.AreEqual(1.0f, eventArgs.Progress);
+ Assert.AreEqual(DownloadStatus.Completed, eventArgs.Status);
+ }
+
+ [TestMethod]
+ public void ModelDownloadEventArgsWithoutWarningMessageIsNull()
+ {
+ // Arrange
+ var eventArgs = new ModelDownloadEventArgs
+ {
+ Progress = 0.5f,
+ Status = DownloadStatus.InProgress
+ };
+
+ // Act & Assert
+ Assert.IsNull(eventArgs.WarningMessage);
+ }
+
+ [TestMethod]
+ public void DownloadStatusHasExpectedValues()
+ {
+ // This test ensures the DownloadStatus enum has expected values
+ // Critical for FoundryLocal integration which uses these states
+
+ // Assert
+ Assert.IsTrue(Enum.IsDefined(DownloadStatus.Waiting));
+ Assert.IsTrue(Enum.IsDefined(DownloadStatus.InProgress));
+ Assert.IsTrue(Enum.IsDefined(DownloadStatus.Completed));
+ Assert.IsTrue(Enum.IsDefined(DownloadStatus.Canceled));
+ }
+
+ [TestMethod]
+ public void FoundryLocalModelDownloadIsSubclassOfModelDownload()
+ {
+ // Arrange
+ var foundryDownloadType = Type.GetType("AIDevGallery.Utils.FoundryLocalModelDownload, AIDevGallery");
+ var modelDownloadType = typeof(ModelDownload);
+
+ // Assert
+ Assert.IsNotNull(foundryDownloadType, "FoundryLocalModelDownload type should exist");
+ Assert.IsTrue(
+ modelDownloadType.IsAssignableFrom(foundryDownloadType),
+ "FoundryLocalModelDownload should inherit from ModelDownload");
+ }
+
+ [TestMethod]
+ public void FoundryLocalModelDownloadConstructorInitializesWithModelDetails()
+ {
+ // Arrange
+ var modelDetails = new ModelDetails
+ {
+ Name = "test-model",
+ Url = "fl://test-model"
+ };
+
+ // Act
+ var download = CreateFoundryLocalModelDownload(modelDetails);
+
+ // Assert
+ Assert.IsNotNull(download);
+ var details = GetProperty(download, "Details");
+ Assert.AreEqual(modelDetails.Name, details.Name);
+ Assert.AreEqual(modelDetails.Url, details.Url);
+ }
+
+ [TestMethod]
+ public void ModelDownloadWarningMessagePropertyExists()
+ {
+ // Verify that the WarningMessage property was added to ModelDownload base class
+ var modelDownloadType = typeof(ModelDownload);
+ var warningProperty = modelDownloadType.GetProperty("WarningMessage");
+
+ Assert.IsNotNull(warningProperty, "WarningMessage property should exist on ModelDownload");
+ Assert.AreEqual(typeof(string), warningProperty.PropertyType);
+ }
+
+ private static object CreateFoundryLocalModelDownload(ModelDetails modelDetails)
+ {
+ var type = Type.GetType("AIDevGallery.Utils.FoundryLocalModelDownload, AIDevGallery");
+ Assert.IsNotNull(type, "FoundryLocalModelDownload type not found");
+
+ var constructor = type.GetConstructor(new[] { typeof(ModelDetails) });
+ Assert.IsNotNull(constructor, "Constructor not found");
+
+ return constructor.Invoke(new object[] { modelDetails });
+ }
+
+ private static T GetProperty(object obj, string propertyName)
+ {
+ var property = obj.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.IsNotNull(property, $"Property {propertyName} not found");
+ return (T)property.GetValue(obj)!;
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj
index ccf7ada1..faeaca95 100644
--- a/AIDevGallery/AIDevGallery.csproj
+++ b/AIDevGallery/AIDevGallery.csproj
@@ -17,6 +17,8 @@
$(NoWarn);IL2050
$(NoWarn);IL2026
+
+ $(NoWarn);IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025
true
SamplesRoots.xml
@@ -80,6 +82,7 @@
+
@@ -351,6 +354,9 @@
+
+
+
Designer
diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml
index f2919cb1..75b5483e 100644
--- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml
+++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml
@@ -97,17 +97,17 @@
AutomationProperties.Name="More info"
Style="{StaticResource TertiaryButtonStyle}"
ToolTipService.ToolTip="More info"
- Visibility="{x:Bind utils:XamlHelpers.VisibleWhenNotNull(FoundryCatalogModel)}">
+ Visibility="{x:Bind utils:XamlHelpers.VisibleWhenNotNull(FoundryCatalogModel.Runtime)}">
-
+
-
+
-
+
@@ -185,17 +185,6 @@
x:Name="DownloadableModelsTxt"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Available models on Foundry Local" />
-
-
-
-
-
-
-
+
- Install Foundry Local
-
+
+ Learn more about Foundry Local
+
diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs
index e625f9c9..eef6714b 100644
--- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs
+++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs
@@ -5,6 +5,7 @@
using AIDevGallery.ExternalModelUtils.FoundryLocal;
using AIDevGallery.Models;
using AIDevGallery.ViewModels;
+using Microsoft.AI.Foundry.Local;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Collections.Generic;
@@ -22,7 +23,7 @@ internal sealed partial class FoundryLocalPickerView : BaseModelPickerView
{
private ObservableCollection AvailableModels { get; } = [];
private ObservableCollection CatalogModels { get; } = [];
- private string FoundryLocalUrl => FoundryLocalModelProvider.Instance?.Url ?? string.Empty;
+ private List _currentModelTypes = [];
public FoundryLocalPickerView()
{
@@ -33,11 +34,12 @@ public FoundryLocalPickerView()
private void ModelDownloadQueue_ModelDownloadCompleted(object? sender, Utils.ModelDownloadCompletedEventArgs e)
{
- _ = Load([]);
+ _ = Load(_currentModelTypes);
}
public override async Task Load(List types)
{
+ _currentModelTypes = types;
VisualStateManager.GoToState(this, "ShowLoading", true);
if (!await FoundryLocalModelProvider.Instance.IsAvailable())
@@ -49,11 +51,16 @@ public override async Task Load(List types)
AvailableModels.Clear();
CatalogModels.Clear();
+ var requiredTasks = FoundryLocalModelProvider.GetRequiredTasksForModelTypes(types);
+
foreach (var model in await FoundryLocalModelProvider.Instance.GetModelsAsync(ignoreCached: true) ?? [])
{
if (model.ProviderModelDetails is FoundryCatalogModel foundryModel)
{
- AvailableModels.Add(new(foundryModel.Alias, model, foundryModel));
+ if (requiredTasks.Count == 0 || requiredTasks.Contains(foundryModel.Task ?? string.Empty))
+ {
+ AvailableModels.Add(new(foundryModel.Alias, model, foundryModel));
+ }
}
else
{
@@ -65,24 +72,28 @@ public override async Task Load(List types)
var catalogModels = catalogModelsDict.Values
.Select(m => (m.ProviderModelDetails as FoundryCatalogModel)!)
+ .Where(f => requiredTasks.Count == 0 || requiredTasks.Contains(f?.Task ?? string.Empty))
.GroupBy(f => f!.Alias)
.OrderByDescending(f => f.Key);
- foreach (var m in catalogModels)
+ foreach (var modelGroup in catalogModels)
{
- var firstModel = m.FirstOrDefault(m => !AvailableModels.Any(cm => cm.ModelDetails.Name == m.Name));
- if (firstModel == null)
+ var notDownloadedModels = modelGroup
+ .Where(model => !AvailableModels.Any(cm => cm.ModelDetails.Name == model.Name))
+ .ToList();
+
+ if (notDownloadedModels.Count == 0)
{
continue;
}
- // DownloadableModels.Add(new DownloadableModel(m));
+ var firstModel = notDownloadedModels[0];
CatalogModels.Add(new FoundryCatalogModelGroup(
- m.Key,
- firstModel!.License.ToLowerInvariant(),
- m.Select(m => new FoundryCatalogModelDetails(m.Runtime, m.FileSizeMb * 1024 * 1024)),
- m.Where(m => !AvailableModels.Any(cm => cm.ModelDetails.Name == m.Name))
- .Select(m => new DownloadableModel(catalogModelsDict[m.Name]))));
+ modelGroup.Key,
+ firstModel.License.ToLowerInvariant(),
+ modelGroup.Where(model => model.Runtime != null)
+ .Select(model => new FoundryCatalogModelDetails(model.Runtime!, model.FileSizeMb * 1024 * 1024)),
+ notDownloadedModels.Select(model => new DownloadableModel(catalogModelsDict[model.Name]))));
}
VisualStateManager.GoToState(this, "ShowModels", true);
@@ -115,16 +126,11 @@ public override void SelectModel(ModelDetails? modelDetails)
if (modelToSelect != null)
{
DispatcherQueue.TryEnqueue(() => ModelSelectionItemsView.Select(AvailableModels.IndexOf(modelToSelect)));
- }
- else
- {
- DispatcherQueue.TryEnqueue(() => ModelSelectionItemsView.DeselectAll());
+ return;
}
}
- else
- {
- DispatcherQueue.TryEnqueue(() => ModelSelectionItemsView.DeselectAll());
- }
+
+ DispatcherQueue.TryEnqueue(() => ModelSelectionItemsView.DeselectAll());
}
private void DownloadModelButton_Click(object sender, RoutedEventArgs e)
@@ -138,19 +144,19 @@ private void DownloadModelButton_Click(object sender, RoutedEventArgs e)
internal static string GetExecutionProviderTextFromModel(ModelDetails model)
{
var foundryModel = model.ProviderModelDetails as FoundryCatalogModel;
- if (foundryModel == null)
+ if (foundryModel == null || foundryModel.Runtime == null)
{
return string.Empty;
}
- return $"Download {GetShortExectionProvider(foundryModel.Runtime.ExecutionProvider)} variant";
+ return $"Download {GetShortExecutionProvider(foundryModel.Runtime.ExecutionProvider)} variant";
}
- internal static string GetShortExectionProvider(string provider)
+ internal static string GetShortExecutionProvider(string? provider)
{
if (string.IsNullOrWhiteSpace(provider))
{
- return provider;
+ return string.Empty;
}
var shortprovider = provider.Split(
@@ -160,10 +166,26 @@ internal static string GetShortExectionProvider(string provider)
return string.IsNullOrWhiteSpace(shortprovider) ? provider : shortprovider;
}
- private void CopyUrlButton_Click(object sender, RoutedEventArgs e)
+ private async void RetryButton_Click(object sender, RoutedEventArgs e)
{
- var dataPackage = new DataPackage();
- dataPackage.SetText(FoundryLocalUrl);
- Clipboard.SetContentWithOptions(dataPackage, null);
+ VisualStateManager.GoToState(this, "ShowLoading", true);
+
+ try
+ {
+ var success = await FoundryLocalModelProvider.Instance.RetryInitializationAsync();
+
+ if (success)
+ {
+ await Load(_currentModelTypes);
+ }
+ else
+ {
+ VisualStateManager.GoToState(this, "ShowNotAvailable", true);
+ }
+ }
+ catch
+ {
+ VisualStateManager.GoToState(this, "ShowNotAvailable", true);
+ }
}
}
\ No newline at end of file
diff --git a/AIDevGallery/ExcludeExtraLibs.props b/AIDevGallery/ExcludeExtraLibs.props
new file mode 100644
index 00000000..f8e74f0f
--- /dev/null
+++ b/AIDevGallery/ExcludeExtraLibs.props
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs
index a8a3828b..3da27498 100644
--- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs
+++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs
@@ -1,100 +1,28 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-using System.Collections.Generic;
-using System.Text.Json;
-using System.Text.Json.Serialization;
+using Microsoft.AI.Foundry.Local;
namespace AIDevGallery.ExternalModelUtils.FoundryLocal;
-internal record PromptTemplate
-{
- [JsonPropertyName("assistant")]
- public string Assistant { get; init; } = default!;
-
- [JsonPropertyName("prompt")]
- public string Prompt { get; init; } = default!;
-}
-
-internal record Runtime
-{
- [JsonPropertyName("deviceType")]
- public string DeviceType { get; init; } = default!;
+internal record FoundryCachedModelInfo(string Name, string? Id);
- [JsonPropertyName("executionProvider")]
- public string ExecutionProvider { get; init; } = default!;
-}
+internal record FoundryDownloadResult(bool Success, string? ErrorMessage);
-internal record ModelSettings
+internal static class ModelTaskTypes
{
- // The sample shows an empty array; keep it open‑ended.
- [JsonPropertyName("parameters")]
- public List Parameters { get; init; } = [];
+ public const string ChatCompletion = "chat-completion";
+ public const string AutomaticSpeechRecognition = "automatic-speech-recognition";
}
-internal record FoundryCachedModel(string Name, string? Id);
-
-internal record FoundryDownloadResult(bool Success, string? ErrorMessage);
-
-internal record FoundryModelDownload(
- string Name,
- string Uri,
- string Path,
- string ProviderType,
- PromptTemplate PromptTemplate);
-
-internal record FoundryDownloadBody(FoundryModelDownload Model, bool IgnorePipeReport);
-
internal record FoundryCatalogModel
{
- [JsonPropertyName("name")]
public string Name { get; init; } = default!;
-
- [JsonPropertyName("displayName")]
public string DisplayName { get; init; } = default!;
-
- [JsonPropertyName("providerType")]
- public string ProviderType { get; init; } = default!;
-
- [JsonPropertyName("uri")]
- public string Uri { get; init; } = default!;
-
- [JsonPropertyName("version")]
- public string Version { get; init; } = default!;
-
- [JsonPropertyName("modelType")]
- public string ModelType { get; init; } = default!;
-
- [JsonPropertyName("promptTemplate")]
- public PromptTemplate PromptTemplate { get; init; } = default!;
-
- [JsonPropertyName("publisher")]
- public string Publisher { get; init; } = default!;
-
- [JsonPropertyName("task")]
- public string Task { get; init; } = default!;
-
- [JsonPropertyName("runtime")]
- public Runtime Runtime { get; init; } = default!;
-
- [JsonPropertyName("fileSizeMb")]
- public long FileSizeMb { get; init; }
-
- [JsonPropertyName("modelSettings")]
- public ModelSettings ModelSettings { get; init; } = default!;
-
- [JsonPropertyName("alias")]
public string Alias { get; init; } = default!;
-
- [JsonPropertyName("supportsToolCalling")]
- public bool SupportsToolCalling { get; init; }
-
- [JsonPropertyName("license")]
+ public long FileSizeMb { get; init; }
public string License { get; init; } = default!;
-
- [JsonPropertyName("licenseDescription")]
- public string LicenseDescription { get; init; } = default!;
-
- [JsonPropertyName("parentModelUri")]
- public string ParentModelUri { get; init; } = default!;
+ public string ModelId { get; init; } = default!;
+ public Runtime? Runtime { get; init; }
+ public string? Task { get; init; }
}
\ No newline at end of file
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs
index a88709ad..2831d32e 100644
--- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs
+++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs
@@ -1,224 +1,281 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
+using Microsoft.AI.Foundry.Local;
+using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Text;
-using System.Text.Json;
-using System.Text.RegularExpressions;
+using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace AIDevGallery.ExternalModelUtils.FoundryLocal;
-internal class FoundryClient
+internal class FoundryClient : IDisposable
{
+ private readonly Dictionary _loadedModels = new();
+ private readonly Dictionary _modelMaxOutputTokens = new();
+ private readonly Dictionary _chatClients = new();
+ private readonly SemaphoreSlim _loadLock = new(1, 1);
+ private FoundryLocalManager? _manager;
+ private ICatalog? _catalog;
+ private bool _disposed;
+
+ ///
+ /// Gets the underlying catalog for direct access to model queries.
+ /// Provider layer should use this to implement business logic.
+ ///
+ public ICatalog? Catalog => _catalog;
+
public static async Task CreateAsync()
{
- var serviceManager = FoundryServiceManager.TryCreate();
- if (serviceManager == null)
+ try
{
- return null;
- }
+ var config = new Configuration
+ {
+ AppName = "AIDevGallery",
+ LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Warning,
+ ModelCacheDir = App.ModelCache.GetCacheFolder()
+ };
- if (!await serviceManager.IsRunning())
- {
- if (!await serviceManager.StartService())
+ await FoundryLocalManager.CreateAsync(config, NullLogger.Instance);
+
+ if (!FoundryLocalManager.IsInitialized)
{
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ClientInitialization", "ManagerCreation", "N/A", "FoundryLocalManager failed to initialize");
return null;
}
- }
- var serviceUrl = await serviceManager.GetServiceUrl();
+ var client = new FoundryClient
+ {
+ _manager = FoundryLocalManager.Instance
+ };
- if (string.IsNullOrEmpty(serviceUrl))
+ await client._manager.EnsureEpsDownloadedAsync();
+ client._catalog = await client._manager.GetCatalogAsync();
+
+ return client;
+ }
+ catch (Exception ex)
{
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ClientInitialization", "Exception", "N/A", ex.Message);
return null;
}
-
- return new FoundryClient(serviceUrl, serviceManager, new HttpClient());
- }
-
- public FoundryServiceManager ServiceManager { get; init; }
-
- private HttpClient _httpClient;
- private string _baseUrl;
- private List _catalogModels = [];
-
- private FoundryClient(string baseUrl, FoundryServiceManager serviceManager, HttpClient httpClient)
- {
- this.ServiceManager = serviceManager;
- this._baseUrl = baseUrl;
- this._httpClient = httpClient;
}
- public async Task> ListCatalogModels()
+ public async Task DownloadModel(FoundryCatalogModel catalogModel, IProgress? progress, CancellationToken cancellationToken = default)
{
- if (_catalogModels.Count > 0)
+ if (_catalog == null)
{
- return _catalogModels;
+ return new FoundryDownloadResult(false, "Catalog not initialized");
}
+ var startTime = DateTime.Now;
try
{
- var response = await _httpClient.GetAsync($"{_baseUrl}/foundry/list");
- response.EnsureSuccessStatusCode();
+ var model = await _catalog.GetModelAsync(catalogModel.Alias);
+ if (model == null)
+ {
+ return new FoundryDownloadResult(false, "Model not found in catalog");
+ }
- var models = await JsonSerializer.DeserializeAsync(
- response.Content.ReadAsStream(),
- FoundryJsonContext.Default.ListFoundryCatalogModel);
+ if (await model.IsCachedAsync())
+ {
+ await EnsureModelLoadedAsync(catalogModel.Alias, cancellationToken);
+ return new FoundryDownloadResult(true, "Model already cached and loaded");
+ }
- if (models != null && models.Count > 0)
+ // Key Perf Log
+ Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] Starting download for model: {catalogModel.Alias}");
+ await model.DownloadAsync(
+ progressPercent => progress?.Report(progressPercent / 100f),
+ cancellationToken);
+
+ // Key Perf Log
+ Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] Download completed for model: {catalogModel.Alias}");
+
+ var duration = (DateTime.Now - startTime).TotalSeconds;
+ Telemetry.Events.FoundryLocalOperationEvent.Log("ModelDownload", catalogModel.Alias, duration);
+
+ try
+ {
+ await EnsureModelLoadedAsync(catalogModel.Alias, cancellationToken);
+ return new FoundryDownloadResult(true, null);
+ }
+ catch (Exception ex)
{
- models.ForEach(_catalogModels.Add);
+ var warningMsg = ex.Message.Split('\n')[0];
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDownload", "LoadWarning", catalogModel.Alias, ex.Message);
+ Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] Load warning: {warningMsg}");
+ return new FoundryDownloadResult(true, warningMsg);
}
}
- catch
+ catch (Exception e)
{
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDownload", "Exception", catalogModel.Alias, e.Message);
+ return new FoundryDownloadResult(false, e.Message);
}
-
- return _catalogModels;
}
- public async Task> ListCachedModels()
+ ///
+ /// Ensures a model is loaded into memory for use.
+ /// Should be called after download or when first accessing a cached model.
+ /// Thread-safe: multiple concurrent calls for the same alias will only load once.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task EnsureModelLoadedAsync(string alias, CancellationToken cancellationToken = default)
{
- var response = await _httpClient.GetAsync($"{_baseUrl}/openai/models");
- response.EnsureSuccessStatusCode();
+ if (_loadedModels.ContainsKey(alias))
+ {
+ return;
+ }
- var catalogModels = await ListCatalogModels();
+ var startTime = DateTime.Now;
+ await _loadLock.WaitAsync(cancellationToken);
+ try
+ {
+ // Double-check inside lock to ensure thread safety
+ if (_loadedModels.ContainsKey(alias))
+ {
+ return;
+ }
- var content = await response.Content.ReadAsStringAsync();
- var modelIds = content.Trim('[', ']').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(id => id.Trim('"'));
+ if (_catalog == null || _manager == null)
+ {
+ throw new InvalidOperationException("FoundryLocal client not initialized");
+ }
- List models = [];
+ var model = await _catalog.GetModelAsync(alias);
+ if (model == null)
+ {
+ throw new InvalidOperationException($"Model with alias '{alias}' not found in catalog");
+ }
- foreach (var id in modelIds)
- {
- var model = catalogModels.FirstOrDefault(m => m.Name == id);
- if (model != null)
+ if (!await model.IsCachedAsync())
{
- models.Add(new FoundryCachedModel(id, model.Alias));
+ throw new InvalidOperationException($"Model with alias '{alias}' is not cached. Please download it first.");
}
- else
+
+ if (!await model.IsLoadedAsync())
{
- models.Add(new FoundryCachedModel(id, null));
+ // Key Perf Log
+ Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] Loading model: {alias} ({model.SelectedVariant.Info.Id})");
+ await model.LoadAsync(cancellationToken);
+
+ // Key Perf Log
+ Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] Model loaded: {alias}");
}
- }
- return models;
+ _loadedModels[alias] = model;
+ _modelMaxOutputTokens[alias] = (int?)model.SelectedVariant.Info.MaxOutputTokens;
+
+ // Pre-create and cache the chat client to avoid sync-over-async in GetChatClient
+ var chatClient = await model.GetChatClientAsync();
+ _chatClients[alias] = chatClient;
+
+ var duration = (DateTime.Now - startTime).TotalSeconds;
+ Telemetry.Events.FoundryLocalOperationEvent.Log("ModelLoad", alias, duration);
+ }
+ catch (Exception ex) when (ex is not InvalidOperationException)
+ {
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ModelLoad", "Exception", alias, ex.Message);
+ throw;
+ }
+ finally
+ {
+ _loadLock.Release();
+ }
}
- public async Task DownloadModel(FoundryCatalogModel model, IProgress? progress, CancellationToken cancellationToken = default)
- {
- var models = await ListCachedModels();
+ public IModel? GetLoadedModel(string alias) =>
+ _loadedModels.GetValueOrDefault(alias);
+
+ public OpenAIChatClient? GetChatClient(string alias) =>
+ _chatClients.GetValueOrDefault(alias);
+
+ public int? GetModelMaxOutputTokens(string alias) =>
+ _modelMaxOutputTokens.GetValueOrDefault(alias);
- if (models.Any(m => m.Name == model.Name))
+ public async Task DeleteModelAsync(string modelId)
+ {
+ if (_catalog == null)
{
- return new(true, "Model already downloaded");
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDelete", "CatalogNotInitialized", modelId, "Catalog not initialized");
+ return false;
}
- return await Task.Run(async () =>
+ try
{
- try
+ var variant = await _catalog.GetModelVariantAsync(modelId);
+ if (variant == null)
{
- var uploadBody = new FoundryDownloadBody(
- new FoundryModelDownload(
- Name: model.Name,
- Uri: model.Uri,
- Path: await GetModelPath(model.Uri), // temporary
- ProviderType: model.ProviderType,
- PromptTemplate: model.PromptTemplate),
- IgnorePipeReport: true);
+ return false;
+ }
- string body = JsonSerializer.Serialize(
- uploadBody,
- FoundryJsonContext.Default.FoundryDownloadBody);
+ var alias = variant.Alias;
- using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/openai/download")
- {
- Content = new StringContent(body, Encoding.UTF8, "application/json")
- };
+ if (await variant.IsLoadedAsync())
+ {
+ await variant.UnloadAsync();
+ }
- using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ if (await variant.IsCachedAsync())
+ {
+ await variant.RemoveFromCacheAsync();
+ }
- response.EnsureSuccessStatusCode();
+ if (!string.IsNullOrEmpty(alias))
+ {
+ _loadedModels.Remove(alias);
+ _modelMaxOutputTokens.Remove(alias);
+ _chatClients.Remove(alias);
+ }
- using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
- using var reader = new StreamReader(stream);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDelete", "Exception", modelId, ex.Message);
+ return false;
+ }
+ }
- string? finalJson = null;
- var line = await reader.ReadLineAsync(cancellationToken);
+ public async Task UnloadAllModelsAsync()
+ {
+ var modelCount = _loadedModels.Count;
- while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
+ // Unload all loaded models before clearing
+ foreach (var (alias, model) in _loadedModels)
+ {
+ try
+ {
+ if (await model.IsLoadedAsync())
{
- cancellationToken.ThrowIfCancellationRequested();
- line = await reader.ReadLineAsync(cancellationToken);
- if (line is null)
- {
- continue;
- }
-
- line = line.Trim();
-
- // Final response starts with '{'
- if (finalJson != null || line.StartsWith('{'))
- {
- finalJson += line;
- continue;
- }
-
- var match = Regex.Match(line, @"\d+(\.\d+)?%");
- if (match.Success)
- {
- var percentage = match.Value;
- if (float.TryParse(percentage.TrimEnd('%'), out float progressValue))
- {
- progress?.Report(progressValue / 100);
- }
- }
+ await model.UnloadAsync();
}
-
- // Parse closing JSON; default if malformed
- var result = finalJson is not null
- ? JsonSerializer.Deserialize(finalJson, FoundryJsonContext.Default.FoundryDownloadResult)!
- : new FoundryDownloadResult(false, "Missing final result from server.");
-
- return result;
}
- catch (Exception e)
+ catch (Exception ex)
{
- return new FoundryDownloadResult(false, e.Message);
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ModelUnload", "Exception", alias, ex.Message);
}
- });
+ }
+
+ _loadedModels.Clear();
+ _modelMaxOutputTokens.Clear();
+ _chatClients.Clear();
}
- // this is a temporary function to get the model path from the blob storage
- // it will be removed once the tag is available in the list response
- private async Task GetModelPath(string assetId)
+ public void Dispose()
{
- var registryUri =
- $"https://eastus.api.azureml.ms/modelregistry/v1.0/registry/models/nonazureaccount?assetId={Uri.EscapeDataString(assetId)}";
-
- using var resp = await _httpClient.GetAsync(registryUri);
- resp.EnsureSuccessStatusCode();
-
- await using var jsonStream = await resp.Content.ReadAsStreamAsync();
- var jsonRoot = await JsonDocument.ParseAsync(jsonStream);
- var blobSasUri = jsonRoot.RootElement.GetProperty("blobSasUri").GetString()!;
-
- var uriBuilder = new UriBuilder(blobSasUri);
- var existingQuery = string.IsNullOrWhiteSpace(uriBuilder.Query)
- ? string.Empty
- : uriBuilder.Query.TrimStart('?') + "&";
-
- uriBuilder.Query = existingQuery + "restype=container&comp=list&delimiter=/";
-
- var listXml = await _httpClient.GetStringAsync(uriBuilder.Uri);
+ if (_disposed)
+ {
+ return;
+ }
- var match = Regex.Match(listXml, @"(.*?)\/<\/Name>");
- return match.Success ? match.Groups[1].Value : string.Empty;
+ _loadedModels.Clear();
+ _modelMaxOutputTokens.Clear();
+ _chatClients.Clear();
+ _loadLock.Dispose();
+ _disposed = true;
}
}
\ No newline at end of file
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs
deleted file mode 100644
index 5220336b..00000000
--- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
-// Licensed under the MIT License.
-
-using System.Collections.Generic;
-using System.Text.Json.Serialization;
-
-namespace AIDevGallery.ExternalModelUtils.FoundryLocal;
-
-[JsonSourceGenerationOptions(
- PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
- WriteIndented = false)]
-[JsonSerializable(typeof(FoundryCatalogModel))]
-[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(FoundryDownloadResult))]
-[JsonSerializable(typeof(FoundryDownloadBody))]
-internal partial class FoundryJsonContext : JsonSerializerContext
-{
-}
\ No newline at end of file
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs
new file mode 100644
index 00000000..72a07c16
--- /dev/null
+++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs
@@ -0,0 +1,144 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.AI;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AIDevGallery.ExternalModelUtils.FoundryLocal;
+
+///
+/// Adapter that wraps FoundryLocal SDK's native OpenAIChatClient to work with Microsoft.Extensions.AI.IChatClient.
+/// Uses the SDK's direct model API (no web service) to avoid SSE compatibility issues.
+///
+internal class FoundryLocalChatClientAdapter : IChatClient
+{
+ private const int DefaultMaxTokens = 1024;
+
+ private readonly Microsoft.AI.Foundry.Local.OpenAIChatClient _chatClient;
+ private readonly string _modelId;
+ private readonly int? _modelMaxOutputTokens;
+
+ public FoundryLocalChatClientAdapter(Microsoft.AI.Foundry.Local.OpenAIChatClient chatClient, string modelId, int? modelMaxOutputTokens = null)
+ {
+ _modelId = modelId;
+ _chatClient = chatClient;
+ _modelMaxOutputTokens = modelMaxOutputTokens;
+ }
+
+ public ChatClientMetadata Metadata => new("FoundryLocal", new Uri($"foundrylocal:///{_modelId}"), _modelId);
+
+ public Task GetResponseAsync(
+ IEnumerable chatMessages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ GetStreamingResponseAsync(chatMessages, options, cancellationToken).ToChatResponseAsync(cancellationToken: cancellationToken);
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable chatMessages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ApplyChatOptions(options);
+ var openAIMessages = ConvertToOpenAIMessages(chatMessages);
+
+ // Key Perf Log
+ System.Diagnostics.Debug.WriteLine($"[{System.DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] Starting inference");
+ var streamingResponse = _chatClient.CompleteChatStreamingAsync(openAIMessages, cancellationToken);
+
+ string responseId = Guid.NewGuid().ToString("N");
+ int chunkCount = 0;
+ await foreach (var chunk in streamingResponse)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (chunkCount == 0)
+ {
+ // Key Perf Log
+ System.Diagnostics.Debug.WriteLine($"[{System.DateTime.Now:HH:mm:ss.fff}] [FoundryLocal] First token received");
+ }
+
+ chunkCount++;
+ if (chunk.Choices != null && chunk.Choices.Count > 0)
+ {
+ var content = chunk.Choices[0].Message?.Content;
+ if (!string.IsNullOrEmpty(content))
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, content)
+ {
+ ResponseId = responseId
+ };
+ }
+ }
+ }
+
+ if (chunkCount == 0)
+ {
+ var errorMessage = $"The model '{_modelId}' did not generate any output. " +
+ "Please verify you have selected an appropriate language model.";
+ Telemetry.Events.FoundryLocalErrorEvent.Log("ChatStreaming", "NoOutput", _modelId, errorMessage);
+ throw new InvalidOperationException(errorMessage);
+ }
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return serviceType?.IsInstanceOfType(this) == true ? this : null;
+ }
+
+ public void Dispose()
+ {
+ // ChatClient doesn't need disposal
+ }
+
+ private void ApplyChatOptions(ChatOptions? options)
+ {
+ // CRITICAL: MaxTokens must be set, otherwise some models won't generate any output
+ _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? _modelMaxOutputTokens ?? DefaultMaxTokens;
+
+ if (options?.Temperature is float temperature)
+ {
+ _chatClient.Settings.Temperature = temperature;
+ }
+
+ if (options?.TopP is float topP)
+ {
+ _chatClient.Settings.TopP = topP;
+ }
+
+ if (options?.TopK is int topK)
+ {
+ _chatClient.Settings.TopK = topK;
+ }
+
+ if (options?.FrequencyPenalty is float frequencyPenalty)
+ {
+ _chatClient.Settings.FrequencyPenalty = frequencyPenalty;
+ }
+
+ if (options?.PresencePenalty is float presencePenalty)
+ {
+ _chatClient.Settings.PresencePenalty = presencePenalty;
+ }
+
+ if (options?.Seed is long seed)
+ {
+ _chatClient.Settings.RandomSeed = (int)seed;
+ }
+ }
+
+ ///
+ /// Converts Microsoft.Extensions.AI chat messages to OpenAI-compatible format.
+ ///
+ private static List ConvertToOpenAIMessages(IEnumerable messages)
+ {
+ return messages.Select(m => new Betalgo.Ranul.OpenAI.ObjectModels.RequestModels.ChatMessage
+ {
+ Role = m.Role.Value,
+ Content = m.Text ?? string.Empty // NOTE: Only supports text content; multi-modal content (images, etc.) is not handled
+ }).ToList();
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs
deleted file mode 100644
index ad554056..00000000
--- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
-// Licensed under the MIT License.
-
-using System.Diagnostics;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-
-namespace AIDevGallery.ExternalModelUtils.FoundryLocal;
-
-internal class FoundryServiceManager()
-{
- public static FoundryServiceManager? TryCreate()
- {
- if (IsAvailable())
- {
- return new FoundryServiceManager();
- }
-
- return null;
- }
-
- private static bool IsAvailable()
- {
- // run "where foundry" to check if the foundry command is available
- using var p = new Process();
- p.StartInfo.FileName = "where";
- p.StartInfo.Arguments = "foundry";
- p.StartInfo.RedirectStandardOutput = true;
- p.StartInfo.RedirectStandardError = true;
- p.StartInfo.UseShellExecute = false;
- p.StartInfo.CreateNoWindow = true;
- p.Start();
- p.WaitForExit();
- return p.ExitCode == 0;
- }
-
- private string? GetUrl(string output)
- {
- var match = Regex.Match(output, @"https?:\/\/[^\/]+:\d+");
- if (match.Success)
- {
- return match.Value;
- }
-
- return null;
- }
-
- public async Task GetServiceUrl()
- {
- var status = await Utils.RunFoundryWithArguments("service status");
-
- if (status.ExitCode != 0 || string.IsNullOrWhiteSpace(status.Output))
- {
- return null;
- }
-
- return GetUrl(status.Output);
- }
-
- public async Task IsRunning()
- {
- var url = await GetServiceUrl();
- return url != null;
- }
-
- public async Task StartService()
- {
- if (await IsRunning())
- {
- return true;
- }
-
- var status = await Utils.RunFoundryWithArguments("service start");
- if (status.ExitCode != 0 || string.IsNullOrWhiteSpace(status.Output))
- {
- return false;
- }
-
- return GetUrl(status.Output) != null;
- }
-}
\ No newline at end of file
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs
deleted file mode 100644
index 22c98504..00000000
--- a/AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
-// Licensed under the MIT License.
-
-using System;
-using System.Diagnostics;
-using System.Threading.Tasks;
-
-namespace AIDevGallery.ExternalModelUtils.FoundryLocal;
-
-internal class Utils
-{
- public async static Task<(string? Output, string? Error, int ExitCode)> RunFoundryWithArguments(string arguments)
- {
- try
- {
- using (var p = new Process())
- {
- p.StartInfo.FileName = "foundry";
- p.StartInfo.Arguments = arguments;
- p.StartInfo.RedirectStandardOutput = true;
- p.StartInfo.RedirectStandardError = true;
- p.StartInfo.UseShellExecute = false;
- p.StartInfo.CreateNoWindow = true;
-
- p.Start();
-
- string output = await p.StandardOutput.ReadToEndAsync();
- string error = await p.StandardError.ReadToEndAsync();
-
- await p.WaitForExitAsync();
-
- return (output, error, p.ExitCode);
- }
- }
- catch
- {
- return (null, null, -1);
- }
- }
-}
\ No newline at end of file
diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs
index b3fe1604..a502ed2f 100644
--- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs
+++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs
@@ -1,13 +1,12 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using AIDevGallery.ExternalModelUtils.FoundryLocal;
using AIDevGallery.Models;
+using AIDevGallery.Telemetry.Events;
using AIDevGallery.Utils;
using Microsoft.Extensions.AI;
-using OpenAI;
using System;
-using System.ClientModel;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -20,7 +19,6 @@ internal class FoundryLocalModelProvider : IExternalModelProvider
private IEnumerable? _downloadedModels;
private IEnumerable? _catalogModels;
private FoundryClient? _foundryManager;
- private string? url;
public static FoundryLocalModelProvider Instance { get; } = new FoundryLocalModelProvider();
@@ -28,41 +26,118 @@ internal class FoundryLocalModelProvider : IExternalModelProvider
public HardwareAccelerator ModelHardwareAccelerator => HardwareAccelerator.FOUNDRYLOCAL;
- public List NugetPackageReferences => ["Microsoft.Extensions.AI.OpenAI"];
+ public List NugetPackageReferences => ["Microsoft.AI.Foundry.Local.WinML", "Microsoft.Extensions.AI"];
public string ProviderDescription => "The model will run locally via Foundry Local";
public string UrlPrefix => "fl://";
public string Icon => $"fl{AppUtils.GetThemeAssetSuffix()}.svg";
- public string Url => url ?? string.Empty;
- public string? IChatClientImplementationNamespace { get; } = "OpenAI";
+ // Note: Foundry Local uses direct SDK calls, not web service, so Url is not applicable
+ public string Url => string.Empty;
+
+ public string? IChatClientImplementationNamespace { get; } = "Microsoft.AI.Foundry.Local";
public string? GetDetailsUrl(ModelDetails details)
{
- throw new NotImplementedException();
+ // Foundry Local models run locally via SDK, no online details page available
+ return null;
}
+ private string ExtractAlias(string url) => url.Replace(UrlPrefix, string.Empty);
+
public IChatClient? GetIChatClient(string url)
{
- var modelId = url.Split('/').LastOrDefault();
- return new OpenAIClient(new ApiKeyCredential("none"), new OpenAIClientOptions
+ var alias = ExtractAlias(url);
+
+ ValidateClient(alias);
+ ValidateModelExistsInCatalog(alias);
+
+ var model = _foundryManager!.GetLoadedModel(alias)
+ ?? throw new InvalidOperationException($"Model '{alias}' is not ready yet. Please call EnsureModelReadyAsync(url) first.");
+
+ var chatClient = _foundryManager.GetChatClient(alias)
+ ?? throw new InvalidOperationException($"Chat client for model '{alias}' was not cached during loading.");
+
+ int? maxOutputTokens = _foundryManager.GetModelMaxOutputTokens(alias);
+ Telemetry.Events.FoundryLocalOperationEvent.Log("GetChatClient", alias);
+ return new FoundryLocal.FoundryLocalChatClientAdapter(chatClient, model.Id, maxOutputTokens);
+ }
+
+ private void ValidateClient(string alias)
+ {
+ if (_foundryManager == null)
+ {
+ LogAndThrow("ClientNotInitialized", alias ?? "unknown", "Foundry Local client not initialized");
+ }
+
+ if (string.IsNullOrEmpty(alias))
+ {
+ LogAndThrow("EmptyAlias", "empty", "Model alias cannot be empty");
+ }
+ }
+
+ private void ValidateModelExistsInCatalog(string alias)
+ {
+ var modelExists = _catalogModels?.Any(m =>
+ ((FoundryCatalogModel)m.ProviderModelDetails!).Alias == alias) ?? false;
+
+ if (!modelExists)
{
- Endpoint = new Uri($"{Url}/v1")
- }).GetChatClient(modelId).AsIChatClient();
+ LogAndThrow("ModelNotFound", alias, $"Model '{alias}' does not exist. Please verify the model alias is correct.");
+ }
+ }
+
+ private void LogAndThrow(string errorType, string alias, string message)
+ {
+ Telemetry.Events.FoundryLocalErrorEvent.Log("GetChatClient", errorType, alias, message);
+ throw new InvalidOperationException(message);
}
public string? GetIChatClientString(string url)
{
- var modelId = url.Split('/').LastOrDefault();
- return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{Url}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()";
+ var alias = ExtractAlias(url);
+
+ if (_foundryManager == null)
+ {
+ return null;
+ }
+
+ var model = _foundryManager.GetLoadedModel(alias);
+ if (model == null)
+ {
+ return null;
+ }
+
+ return $@"// Initialize Foundry Local
+var config = new Configuration
+{{
+ AppName = ""YourApp"",
+ LogLevel = LogLevel.Warning
+}};
+await FoundryLocalManager.CreateAsync(config);
+var manager = FoundryLocalManager.Instance;
+var catalog = await manager.GetCatalogAsync();
+
+// Get and load the model
+var model = await catalog.GetModelAsync(""{alias}"");
+await model.LoadAsync();
+
+// Get chat client and use it
+var chatClient = await model.GetChatClientAsync();
+var messages = new List {{ new ChatMessage(ChatRole.User, ""Your message here"") }};
+await foreach (var chunk in chatClient.CompleteChatStreamingAsync(messages))
+{{
+ // Process streaming response
+ Console.Write(chunk.Choices[0].Delta?.Content);
+}}";
}
public async Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default)
{
if (ignoreCached)
{
- Reset();
+ await ResetAsync();
}
await InitializeAsync(cancelationToken);
@@ -75,25 +150,146 @@ public IEnumerable GetAllModelsInCatalog()
return _catalogModels ?? [];
}
- public async Task DownloadModel(ModelDetails modelDetails, IProgress? progress, CancellationToken cancellationToken = default)
+ ///
+ /// Maps ModelType enums to Foundry Local task type strings for filtering models.
+ ///
+ /// List of ModelType enums to map.
+ /// Set of task type strings (e.g., "chat-completion", "automatic-speech-recognition").
+ public static HashSet GetRequiredTasksForModelTypes(List types)
+ {
+ var requiredTasks = new HashSet();
+
+ foreach (var type in types)
+ {
+ var typeName = type.ToString();
+
+ // Language models and chat-related models use chat-completion
+ if (type == ModelType.LanguageModels ||
+ type == ModelType.PhiSilica ||
+ type == ModelType.PhiSilicaLora ||
+ (typeName.StartsWith("Phi", StringComparison.Ordinal) && !typeName.Contains("Vision")) ||
+ typeName.StartsWith("Mistral", StringComparison.Ordinal) ||
+ type == ModelType.TextSummarizer ||
+ type == ModelType.TextRewriter ||
+ type == ModelType.DescribeYourChange ||
+ type == ModelType.TextToTableConverter)
+ {
+ requiredTasks.Add(ModelTaskTypes.ChatCompletion);
+ }
+
+ // Audio models use automatic-speech-recognition
+ // Currently, AIDG does not have any Sample that support Foundry Local AutomaticSpeechRecognition model.
+ // else if (type == ModelType.AudioModels ||
+ // type == ModelType.Whisper ||
+ // typeName.StartsWith("Whisper", StringComparison.Ordinal))
+ // {
+ // requiredTasks.Add(ModelTaskTypes.AutomaticSpeechRecognition);
+ // }
+
+ // For other model types, no filtering is applied (empty set will show all models)
+ }
+
+ return requiredTasks;
+ }
+
+ ///
+ /// Lists all models available in the Foundry Local catalog.
+ ///
+ private async Task> ListCatalogModelsAsync()
+ {
+ if (_foundryManager?.Catalog == null)
+ {
+ return [];
+ }
+
+ var models = await _foundryManager.Catalog.ListModelsAsync();
+ return models.Select(model =>
+ {
+ var variant = model.SelectedVariant;
+ var info = variant.Info;
+ return new FoundryCatalogModel
+ {
+ Name = info.Name,
+ DisplayName = info.DisplayName ?? info.Name,
+ Alias = model.Alias,
+ FileSizeMb = info.FileSizeMb ?? 0,
+ License = info.License ?? string.Empty,
+ ModelId = variant.Id,
+ Runtime = info.Runtime,
+ Task = info.Task
+ };
+ }).ToList();
+ }
+
+ ///
+ /// Lists all cached (downloaded) models.
+ ///
+ private async Task> ListCachedModelsAsync()
+ {
+ if (_foundryManager?.Catalog == null)
+ {
+ return [];
+ }
+
+ return (await _foundryManager.Catalog.GetCachedModelsAsync())
+ .Select(variant => new FoundryCachedModelInfo(variant.Info.Name, variant.Alias))
+ .ToList();
+ }
+
+ public async Task DownloadModel(ModelDetails modelDetails, IProgress? progress, CancellationToken cancellationToken = default)
{
if (_foundryManager == null)
{
- return false;
+ return new FoundryDownloadResult(false, "Foundry Local manager not initialized");
}
if (modelDetails.ProviderModelDetails is not FoundryCatalogModel model)
{
- return false;
+ return new FoundryDownloadResult(false, "Invalid model details");
}
- return (await _foundryManager.DownloadModel(model, progress, cancellationToken)).Success;
+ var startTime = DateTime.Now;
+ var result = await _foundryManager.DownloadModel(model, progress, cancellationToken);
+ var duration = (DateTime.Now - startTime).TotalSeconds;
+
+ FoundryLocalDownloadEvent.Log(
+ model.Alias,
+ result.Success,
+ result.ErrorMessage,
+ model.FileSizeMb,
+ duration);
+
+ return result;
+ }
+
+ ///
+ /// Resets the provider state by clearing downloaded models cache and unloading all loaded models.
+ /// WARNING: This will unload all currently loaded models. Any ongoing inference will fail.
+ ///
+ private async Task ResetAsync()
+ {
+ _downloadedModels = null;
+
+ if (_foundryManager != null)
+ {
+ await _foundryManager.UnloadAllModelsAsync();
+ }
}
- private void Reset()
+ ///
+ /// Retries initialization of the Foundry Local provider.
+ /// This will reset the provider state and attempt to reinitialize the manager.
+ ///
+ /// True if initialization succeeded, false otherwise.
+ public async Task RetryInitializationAsync()
{
_downloadedModels = null;
- _ = InitializeAsync();
+ _catalogModels = null;
+ _foundryManager = null;
+
+ await InitializeAsync();
+
+ return _foundryManager != null;
}
private async Task InitializeAsync(CancellationToken cancelationToken = default)
@@ -110,56 +306,40 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default)
return;
}
- url = url ?? await _foundryManager.ServiceManager.GetServiceUrl();
-
if (_catalogModels == null || !_catalogModels.Any())
{
- _catalogModels = (await _foundryManager.ListCatalogModels()).Select(m => ToModelDetails(m));
+ _catalogModels = (await ListCatalogModelsAsync()).Select(m => ToModelDetails(m));
}
- var cachedModels = await _foundryManager.ListCachedModels();
+ var cachedModels = await ListCachedModelsAsync();
List downloadedModels = [];
- foreach (var model in _catalogModels)
+ var catalogByAlias = _catalogModels.GroupBy(m => ((FoundryCatalogModel)m.ProviderModelDetails!).Alias).ToList();
+
+ foreach (var aliasGroup in catalogByAlias)
{
- var cachedModel = cachedModels.FirstOrDefault(m => m.Name == model.Name);
+ var firstModel = aliasGroup.First();
+ var catalogModel = (FoundryCatalogModel)firstModel.ProviderModelDetails!;
+ var hasCachedVariant = cachedModels.Any(cm => cm.Id == catalogModel.Alias);
- if (cachedModel != default)
+ if (hasCachedVariant)
{
- model.Id = $"{UrlPrefix}{cachedModel.Id}";
- downloadedModels.Add(model);
- cachedModels.Remove(cachedModel);
+ downloadedModels.Add(firstModel);
}
}
- foreach (var model in cachedModels)
- {
- downloadedModels.Add(new ModelDetails()
- {
- Id = $"fl-{model.Name}",
- Name = model.Name,
- Url = $"{UrlPrefix}{model.Name}",
- Description = $"{model.Name} running locally with Foundry Local",
- HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL],
- SupportedOnQualcomm = true,
- ProviderModelDetails = model
- });
- }
-
_downloadedModels = downloadedModels;
-
- return;
}
private ModelDetails ToModelDetails(FoundryCatalogModel model)
{
- return new ModelDetails()
+ return new ModelDetails
{
- Id = $"fl-{model.Name}",
- Name = model.Name,
- Url = $"{UrlPrefix}{model.Name}",
- Description = $"{model.Alias} running locally with Foundry Local",
+ Id = $"fl-{model.Alias}",
+ Name = model.DisplayName,
+ Url = $"{UrlPrefix}{model.Alias}",
+ Description = $"{model.DisplayName} is running locally with Foundry Local",
HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL],
Size = model.FileSizeMb * 1024 * 1024,
SupportedOnQualcomm = true,
@@ -173,4 +353,150 @@ public async Task IsAvailable()
await InitializeAsync();
return _foundryManager != null;
}
+
+ ///
+ /// Ensures the model is ready to use before calling GetIChatClient.
+ /// This method must be called before GetIChatClient to avoid deadlock.
+ ///
+ /// The model URL.
+ /// Cancellation token.
+ /// A task representing the asynchronous operation.
+ public async Task EnsureModelReadyAsync(string url, CancellationToken cancellationToken = default)
+ {
+ var alias = ExtractAlias(url);
+
+ ValidateClient(alias);
+ ValidateModelExistsInCatalog(alias);
+
+ if (_foundryManager!.GetLoadedModel(alias) != null)
+ {
+ return;
+ }
+
+ await _foundryManager.EnsureModelLoadedAsync(alias, cancellationToken);
+ }
+
+ public async Task> GetCachedModelsWithDetails()
+ {
+ var models = await GetModelsAsync();
+ var result = new List();
+
+ foreach (var modelDetails in models)
+ {
+ if (modelDetails.ProviderModelDetails is not FoundryCatalogModel catalogModel)
+ {
+ continue;
+ }
+
+ string modelPath = string.Empty;
+ if (_foundryManager?.Catalog != null)
+ {
+ var model = await _foundryManager.Catalog.GetModelAsync(catalogModel.Alias);
+ if (model != null)
+ {
+ modelPath = await model.GetPathAsync();
+ }
+ }
+
+ result.Add(new CachedModel(modelDetails, modelPath, false, modelDetails.Size));
+ }
+
+ return result;
+ }
+
+ public async Task DeleteCachedModelAsync(CachedModel cachedModel)
+ {
+ if (_foundryManager == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ if (cachedModel.Details.ProviderModelDetails is FoundryCatalogModel catalogModel)
+ {
+ var result = await _foundryManager.DeleteModelAsync(catalogModel.ModelId);
+ if (result)
+ {
+ if (_downloadedModels != null)
+ {
+ _downloadedModels = _downloadedModels.Where(m =>
+ (m.ProviderModelDetails as FoundryCatalogModel)?.Alias != catalogModel.Alias);
+ }
+ }
+
+ return result;
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Telemetry.Events.FoundryLocalErrorEvent.Log(
+ "CachedModelDelete",
+ "Exception",
+ cachedModel.Details.Name,
+ ex.Message);
+ return false;
+ }
+ }
+
+ public async Task ClearAllCacheAsync()
+ {
+ if (_foundryManager == null)
+ {
+ return true;
+ }
+
+ try
+ {
+ // Get snapshot of cached models to avoid collection modification during enumeration
+ var cachedModels = (await GetCachedModelsWithDetails()).ToList();
+ var allDeleted = true;
+ var deletedCount = 0;
+
+ foreach (var cachedModel in cachedModels)
+ {
+ if (cachedModel.Details.ProviderModelDetails is not FoundryCatalogModel catalogModel)
+ {
+ continue;
+ }
+
+ try
+ {
+ var deleted = await _foundryManager.DeleteModelAsync(catalogModel.ModelId);
+ if (deleted)
+ {
+ deletedCount++;
+ }
+ else
+ {
+ allDeleted = false;
+ }
+ }
+ catch (Exception ex)
+ {
+ Telemetry.Events.FoundryLocalErrorEvent.Log(
+ "ClearAllCache",
+ "ModelDeletion",
+ catalogModel.Alias,
+ ex.Message);
+ allDeleted = false;
+ }
+ }
+
+ await ResetAsync();
+
+ return allDeleted;
+ }
+ catch (Exception ex)
+ {
+ Telemetry.Events.FoundryLocalErrorEvent.Log(
+ "ClearAllCache",
+ "Exception",
+ "all",
+ ex.Message);
+ return false;
+ }
+ }
}
\ No newline at end of file
diff --git a/AIDevGallery/Models/BaseSampleNavigationParameters.cs b/AIDevGallery/Models/BaseSampleNavigationParameters.cs
index 4231c559..a22d5049 100644
--- a/AIDevGallery/Models/BaseSampleNavigationParameters.cs
+++ b/AIDevGallery/Models/BaseSampleNavigationParameters.cs
@@ -34,6 +34,11 @@ public void NotifyCompletion()
}
else if (ExternalModelHelper.IsUrlFromExternalProvider(ChatClientModelPath))
{
+ if (ChatClientHardwareAccelerator == HardwareAccelerator.FOUNDRYLOCAL)
+ {
+ await FoundryLocalModelProvider.Instance.EnsureModelReadyAsync(ChatClientModelPath, CancellationToken).ConfigureAwait(false);
+ }
+
return ExternalModelHelper.GetIChatClient(ChatClientModelPath);
}
diff --git a/AIDevGallery/Models/CachedModel.cs b/AIDevGallery/Models/CachedModel.cs
index bc077955..a0eae513 100644
--- a/AIDevGallery/Models/CachedModel.cs
+++ b/AIDevGallery/Models/CachedModel.cs
@@ -30,6 +30,11 @@ public CachedModel(ModelDetails details, string path, bool isFile, long modelSiz
Url = details.Url;
Source = CachedModelSource.Local;
}
+ else if (details.Url.StartsWith("fl://", StringComparison.OrdinalIgnoreCase))
+ {
+ Url = details.Url;
+ Source = CachedModelSource.FoundryLocal;
+ }
else
{
Url = new HuggingFaceUrl(details.Url).FullUrl;
@@ -48,5 +53,6 @@ internal enum CachedModelSource
{
GitHub,
HuggingFace,
- Local
+ Local,
+ FoundryLocal
}
\ No newline at end of file
diff --git a/AIDevGallery/Pages/SettingsPage.xaml.cs b/AIDevGallery/Pages/SettingsPage.xaml.cs
index 7194ac96..464d6fa8 100644
--- a/AIDevGallery/Pages/SettingsPage.xaml.cs
+++ b/AIDevGallery/Pages/SettingsPage.xaml.cs
@@ -60,7 +60,7 @@ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
base.OnNavigatingFrom(e);
}
- private void GetStorageInfo()
+ private async void GetStorageInfo()
{
cachedModels.Clear();
@@ -68,14 +68,15 @@ private void GetStorageInfo()
FolderPathTxt.Content = cacheFolderPath;
long totalCacheSize = 0;
+ var allModels = await App.ModelCache.GetAllModelsAsync();
- foreach (var cachedModel in App.ModelCache.Models.Where(m => m.Path.StartsWith(cacheFolderPath, StringComparison.OrdinalIgnoreCase)).OrderBy(m => m.Details.Name))
+ foreach (var cachedModel in allModels.OrderBy(m => m.Details.Name))
{
cachedModels.Add(cachedModel);
totalCacheSize += cachedModel.ModelSize;
}
- if (App.ModelCache.Models.Count > 0)
+ if (cachedModels.Count > 0)
{
ModelsExpander.IsExpanded = true;
}
@@ -191,7 +192,7 @@ private void ModelFolder_Click(object sender, RoutedEventArgs e)
path = Path.GetDirectoryName(path);
}
- if (path != null)
+ if (path != null && Directory.Exists(path))
{
Process.Start("explorer.exe", path);
}
diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs
index b8629b94..21519aad 100644
--- a/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs
+++ b/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs
@@ -121,7 +121,7 @@ public void GenerateText(string topic)
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, userPrompt)
],
- null,
+ new() { MaxOutputTokens = _maxTokenLength },
cts.Token))
{
//
diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs b/AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs
new file mode 100644
index 00000000..ea3aadf6
--- /dev/null
+++ b/AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs
@@ -0,0 +1,172 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Diagnostics.Telemetry;
+using Microsoft.Diagnostics.Telemetry.Internal;
+using System;
+using System.Diagnostics.Tracing;
+
+namespace AIDevGallery.Telemetry.Events;
+
+///
+/// Telemetry event for successful Foundry Local operations.
+/// Use this for tracking successful operations like model preparation, loading, etc.
+///
+[EventData]
+internal class FoundryLocalOperationEvent : EventBase
+{
+ internal FoundryLocalOperationEvent(
+ string operation,
+ string modelIdentifier,
+ double? durationSeconds,
+ DateTime eventTime)
+ {
+ Operation = operation;
+ ModelIdentifier = modelIdentifier;
+ DurationSeconds = durationSeconds ?? 0;
+ EventTime = eventTime;
+ }
+
+ public string Operation { get; private set; }
+
+ public string ModelIdentifier { get; private set; }
+
+ public double DurationSeconds { get; private set; }
+
+ public DateTime EventTime { get; private set; }
+
+ public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage;
+
+ public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings)
+ {
+ }
+
+ ///
+ /// Logs a successful Foundry Local operation.
+ ///
+ /// Operation name in PascalCase (e.g., "ModelPrepare", "ModelLoad", "ModelDelete")
+ /// Model alias or ID
+ /// Optional duration in seconds
+ public static void Log(string operation, string modelIdentifier, double? durationSeconds = null)
+ {
+ TelemetryFactory.Get().Log(
+ "FoundryLocalOperation_Event",
+ LogLevel.Info,
+ new FoundryLocalOperationEvent(operation, modelIdentifier, durationSeconds, DateTime.Now));
+ }
+}
+
+///
+/// Telemetry event for Foundry Local model download operations.
+/// Tracks download success/failure, file size, and duration.
+///
+[EventData]
+internal class FoundryLocalDownloadEvent : EventBase
+{
+ internal FoundryLocalDownloadEvent(
+ string modelAlias,
+ bool success,
+ string? errorMessage,
+ long? fileSizeMb,
+ double? durationSeconds,
+ DateTime eventTime)
+ {
+ ModelAlias = modelAlias;
+ Success = success;
+ ErrorMessage = errorMessage ?? string.Empty;
+ FileSizeMb = fileSizeMb ?? 0;
+ DurationSeconds = durationSeconds ?? 0;
+ EventTime = eventTime;
+ }
+
+ public string ModelAlias { get; private set; }
+
+ public bool Success { get; private set; }
+
+ public string ErrorMessage { get; private set; }
+
+ public long FileSizeMb { get; private set; }
+
+ public double DurationSeconds { get; private set; }
+
+ public DateTime EventTime { get; private set; }
+
+ public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage;
+
+ public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings)
+ {
+ }
+
+ public static void Log(
+ string modelAlias,
+ bool success,
+ string? errorMessage = null,
+ long? fileSizeMb = null,
+ double? durationSeconds = null)
+ {
+ if (success)
+ {
+ TelemetryFactory.Get().Log(
+ "FoundryLocalDownload_Event",
+ LogLevel.Info,
+ new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, fileSizeMb, durationSeconds, DateTime.Now));
+ }
+ else
+ {
+ var relatedActivityId = Guid.NewGuid();
+ TelemetryFactory.Get().LogError(
+ "FoundryLocalDownload_Event",
+ LogLevel.Critical,
+ new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, fileSizeMb, durationSeconds, DateTime.Now),
+ relatedActivityId);
+ }
+ }
+}
+
+///
+/// Telemetry event for Foundry Local errors.
+/// Operation naming convention: PascalCase action names (e.g., "ClientInitialization", "ModelDownload", "ModelPrepare")
+///
+[EventData]
+internal class FoundryLocalErrorEvent : EventBase
+{
+ internal FoundryLocalErrorEvent(
+ string operation,
+ string phase,
+ string modelIdentifier,
+ string errorMessage,
+ DateTime eventTime)
+ {
+ Operation = operation;
+ Phase = phase;
+ ModelIdentifier = modelIdentifier;
+ ErrorMessage = errorMessage ?? string.Empty;
+ EventTime = eventTime;
+ }
+
+ public string Operation { get; private set; }
+
+ public string Phase { get; private set; }
+
+ public string ModelIdentifier { get; private set; }
+
+ public string ErrorMessage { get; private set; }
+
+ public DateTime EventTime { get; private set; }
+
+ public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage;
+
+ public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings)
+ {
+ }
+
+ public static void Log(string operation, string phase, string modelIdentifier, string errorMessage)
+ {
+ var relatedActivityId = Guid.NewGuid();
+ TelemetryFactory.Get().LogError(
+ "FoundryLocalError_Event",
+ LogLevel.Critical,
+ new FoundryLocalErrorEvent(operation, phase, modelIdentifier, errorMessage, DateTime.Now),
+ relatedActivityId);
+ }
+}
\ No newline at end of file
diff --git a/AIDevGallery/Utils/ModelCache.cs b/AIDevGallery/Utils/ModelCache.cs
index da9a7f41..0d211c55 100644
--- a/AIDevGallery/Utils/ModelCache.cs
+++ b/AIDevGallery/Utils/ModelCache.cs
@@ -91,6 +91,14 @@ public async Task DeleteModelFromCache(string url)
public async Task DeleteModelFromCache(CachedModel model)
{
ModelDeletedEvent.Log(model.Url);
+
+ if (model.Source == CachedModelSource.FoundryLocal)
+ {
+ var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance;
+ await foundryLocalProvider.DeleteCachedModelAsync(model);
+ return;
+ }
+
await CacheStore.RemoveModel(model);
if (model.Url.StartsWith("local", System.StringComparison.OrdinalIgnoreCase))
@@ -109,10 +117,31 @@ public async Task DeleteModelFromCache(CachedModel model)
}
}
+ public async Task> GetAllModelsAsync()
+ {
+ var allModels = new List(CacheStore.Models);
+
+ var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance;
+ if (await foundryLocalProvider.IsAvailable())
+ {
+ var foundryModels = await foundryLocalProvider.GetCachedModelsWithDetails();
+ allModels.AddRange(foundryModels);
+ }
+
+ return allModels;
+ }
+
public async Task ClearCache()
{
ModelCacheDeletedEvent.Log();
+ // Clear FoundryLocal cache first to prevent directory access conflicts
+ var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance;
+ if (await foundryLocalProvider.IsAvailable())
+ {
+ await foundryLocalProvider.ClearAllCacheAsync();
+ }
+
var cacheDir = GetCacheFolder();
Directory.Delete(cacheDir, true);
await CacheStore.ClearAsync();
diff --git a/AIDevGallery/Utils/ModelDownload.cs b/AIDevGallery/Utils/ModelDownload.cs
index 201fa8c1..454b37ab 100644
--- a/AIDevGallery/Utils/ModelDownload.cs
+++ b/AIDevGallery/Utils/ModelDownload.cs
@@ -68,6 +68,22 @@ protected set
}
}
+ private string? _warningMessage;
+ public string? WarningMessage
+ {
+ get => _warningMessage;
+ protected set
+ {
+ _warningMessage = value;
+ StateChanged?.Invoke(this, new ModelDownloadEventArgs
+ {
+ Progress = DownloadProgress,
+ Status = DownloadStatus,
+ WarningMessage = _warningMessage
+ });
+ }
+ }
+
protected CancellationTokenSource CancellationTokenSource { get; }
public void Dispose()
@@ -329,27 +345,24 @@ public override async Task StartDownload()
{
DownloadStatus = DownloadStatus.InProgress;
- Progress internalProgress = new(p =>
- {
- DownloadProgress = p;
- });
-
- bool result = false;
+ var internalProgress = new Progress(p => DownloadProgress = p);
try
{
- result = await FoundryLocalModelProvider.Instance.DownloadModel(Details, internalProgress, CancellationTokenSource.Token);
- }
- catch
- {
- }
+ var downloadResult = await FoundryLocalModelProvider.Instance.DownloadModel(
+ Details, internalProgress, CancellationTokenSource.Token);
- if (result)
- {
- DownloadStatus = DownloadStatus.Completed;
- return true;
+ if (downloadResult.Success)
+ {
+ DownloadStatus = DownloadStatus.Completed;
+ WarningMessage = downloadResult.ErrorMessage; // May be null or contain warning
+ return true;
+ }
+
+ DownloadStatus = DownloadStatus.Canceled;
+ return false;
}
- else
+ catch
{
DownloadStatus = DownloadStatus.Canceled;
return false;
@@ -373,4 +386,5 @@ internal class ModelDownloadEventArgs
public required float Progress { get; init; }
public required DownloadStatus Status { get; init; }
public string? VerificationFailureMessage { get; init; }
+ public string? WarningMessage { get; init; }
}
\ No newline at end of file
diff --git a/AIDevGallery/Utils/ModelDownloadQueue.cs b/AIDevGallery/Utils/ModelDownloadQueue.cs
index 582c9af1..d07430e8 100644
--- a/AIDevGallery/Utils/ModelDownloadQueue.cs
+++ b/AIDevGallery/Utils/ModelDownloadQueue.cs
@@ -143,16 +143,25 @@ private async Task Download(ModelDownload modelDownload)
{
ModelDownloadCompleteEvent.Log(modelDownload.Details.Url);
ModelDownloadCompleted?.Invoke(this, new ModelDownloadCompletedEventArgs());
- SendNotification(modelDownload.Details);
+ SendNotification(modelDownload.Details, modelDownload.WarningMessage);
}
}
- private static void SendNotification(ModelDetails model)
+ private static void SendNotification(ModelDetails model, string? warningMessage = null)
{
- var builder = new AppNotificationBuilder()
- .AddText(model.Name + " is ready to use.")
- .AddButton(new AppNotificationButton("Try it out")
- .AddArgument("model", model.Id));
+ var builder = new AppNotificationBuilder();
+
+ if (string.IsNullOrEmpty(warningMessage))
+ {
+ builder.AddText(model.Name + " is ready to use.")
+ .AddButton(new AppNotificationButton("Try it out")
+ .AddArgument("model", model.Id));
+ }
+ else
+ {
+ builder.AddText(model.Name + " download completed with warning.")
+ .AddText(warningMessage);
+ }
var notificationManager = AppNotificationManager.Default;
notificationManager.Show(builder.BuildNotification());
diff --git a/Directory.Build.props b/Directory.Build.props
index 0f9c7d8e..18aaea7e 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -11,6 +11,9 @@
Recommended
true
true
+
+
+ $(NoWarn);IDISP001;IDISP003;IDISP017
true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8f3fcfac..174c8c86 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,6 +5,7 @@
+
@@ -24,8 +25,8 @@
-
-
+
+
diff --git a/nuget.config b/nuget.config
index fad1c265..bafe5399 100644
--- a/nuget.config
+++ b/nuget.config
@@ -1,6 +1,16 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file