From 2f001e81502b778ea1e78e6f61f753ea012703f1 Mon Sep 17 00:00:00 2001 From: Kai Tao <69313318+vanzue@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:28:23 +0800 Subject: [PATCH] Advanced paste: Tweak Foundry Local Displayed Model and start server if server is turned on when using AP (#43529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request 1. Foundry local model name should not prefixed by fl:// 2. If foundry service is shutdown, we should not just fail it, we should start it then call FL to make availability better. ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed Verified locally: 1. Manually disable foundry local service, then run AP with foundry local, it can return result instead of direct failure. 2. image image ![Uploading image.png…]() --- .../FoundryLocal/FoundryClient.cs | 8 ++ .../FoundryLocalModelProvider.cs | 33 +++--- .../ILanguageModelProvider.cs | 4 +- .../LanguageModelService.cs | 106 ------------------ .../FoundryLocalPasteProvider.cs | 11 +- .../Views/AdvancedPastePage.xaml.cs | 43 ++----- 6 files changed, 34 insertions(+), 171 deletions(-) delete mode 100644 src/common/LanguageModelProvider/LanguageModelService.cs diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs index 84c38c49f2..c2b0f4ab36 100644 --- a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs @@ -205,4 +205,12 @@ internal sealed class FoundryClient return false; } } + + public async Task EnsureRunning() + { + if (!_foundryManager.IsServiceRunning) + { + await _foundryManager.StartServiceAsync(); + } + } } diff --git a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs index b866ab05d9..3c9c618f7f 100644 --- a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs +++ b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs @@ -13,7 +13,7 @@ namespace LanguageModelProvider; public sealed class FoundryLocalModelProvider : ILanguageModelProvider { private IEnumerable? _downloadedModels; - private FoundryClient? _foundryManager; + private FoundryClient? _foundryClient; private string? _serviceUrl; public static FoundryLocalModelProvider Instance { get; } = new(); @@ -22,13 +22,11 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider public string ProviderDescription => "The model will run locally via Foundry Local"; - public string UrlPrefix => "fl://"; - - public IChatClient? GetIChatClient(string url) + public IChatClient? GetIChatClient(string modelId) { try { - Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {url}"); + Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {modelId}"); InitializeAsync().GetAwaiter().GetResult(); } catch (Exception ex) @@ -37,26 +35,22 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider return null; } - if (string.IsNullOrWhiteSpace(_serviceUrl) || _foundryManager == null) + if (string.IsNullOrWhiteSpace(_serviceUrl) || _foundryClient == null) { Logger.LogError("[FoundryLocal] Service URL or manager is null"); return null; } - // Extract model ID from URL (format: fl://modelname) - var modelId = url.Replace(UrlPrefix, string.Empty).Trim('/'); if (string.IsNullOrWhiteSpace(modelId)) { Logger.LogError("[FoundryLocal] Model ID is empty after extraction"); return null; } - Logger.LogInfo($"[FoundryLocal] Extracted model ID: {modelId}"); - // Ensure the model is loaded before returning chat client try { - var isLoaded = _foundryManager.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); + var isLoaded = _foundryClient.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); if (!isLoaded) { Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}"); @@ -72,7 +66,7 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider } // Use ServiceUri instead of Endpoint since Endpoint already includes /v1 - var baseUri = _foundryManager.GetServiceUri(); + var baseUri = _foundryClient.GetServiceUri(); if (baseUri == null) { Logger.LogError("[FoundryLocal] Service URI is null"); @@ -133,24 +127,25 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider private async Task InitializeAsync(CancellationToken cancelationToken = default) { - if (_foundryManager != null && _downloadedModels != null && _downloadedModels.Any()) + if (_foundryClient != null && _downloadedModels != null && _downloadedModels.Any()) { + await _foundryClient.EnsureRunning().ConfigureAwait(false); return; } Logger.LogInfo("[FoundryLocal] Initializing provider"); - _foundryManager ??= await FoundryClient.CreateAsync(); + _foundryClient ??= await FoundryClient.CreateAsync(); - if (_foundryManager == null) + if (_foundryClient == null) { Logger.LogError("[FoundryLocal] Failed to create Foundry client"); return; } - _serviceUrl ??= await _foundryManager.GetServiceUrl(); + _serviceUrl ??= await _foundryClient.GetServiceUrl(); Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}"); - var cachedModels = await _foundryManager.ListCachedModels(); + var cachedModels = await _foundryClient.ListCachedModels(); Logger.LogInfo($"[FoundryLocal] Found {cachedModels.Count} cached models"); List downloadedModels = []; @@ -162,7 +157,7 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider { Id = $"fl-{model.Name}", Name = model.Name, - Url = $"{UrlPrefix}{model.Name}", + Url = $"fl://{model.Name}", Description = $"{model.Name} running locally with Foundry Local", HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], SupportedOnQualcomm = true, @@ -178,7 +173,7 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider { Logger.LogInfo("[FoundryLocal] Checking availability"); await InitializeAsync(); - var available = _foundryManager != null; + var available = _foundryClient != null; Logger.LogInfo($"[FoundryLocal] Available: {available}"); return available; } diff --git a/src/common/LanguageModelProvider/ILanguageModelProvider.cs b/src/common/LanguageModelProvider/ILanguageModelProvider.cs index 60b3d99386..2bef3fb7f1 100644 --- a/src/common/LanguageModelProvider/ILanguageModelProvider.cs +++ b/src/common/LanguageModelProvider/ILanguageModelProvider.cs @@ -10,13 +10,11 @@ public interface ILanguageModelProvider { string Name { get; } - string UrlPrefix { get; } - string ProviderDescription { get; } Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default); - IChatClient? GetIChatClient(string url); + IChatClient? GetIChatClient(string modelId); string GetIChatClientString(string url); } diff --git a/src/common/LanguageModelProvider/LanguageModelService.cs b/src/common/LanguageModelProvider/LanguageModelService.cs deleted file mode 100644 index 1cfb3b3c49..0000000000 --- a/src/common/LanguageModelProvider/LanguageModelService.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Concurrent; -using Microsoft.Extensions.AI; - -namespace LanguageModelProvider; - -public sealed class LanguageModelService -{ - private readonly ConcurrentDictionary _providersByPrefix; - - public LanguageModelService(IEnumerable providers) - { - ArgumentNullException.ThrowIfNull(providers); - - _providersByPrefix = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var provider in providers) - { - if (!string.IsNullOrWhiteSpace(provider.UrlPrefix)) - { - _providersByPrefix[provider.UrlPrefix] = provider; - } - } - } - - public static LanguageModelService CreateDefault() - { - return new LanguageModelService(new[] - { - FoundryLocalModelProvider.Instance, - }); - } - - public IReadOnlyCollection Providers => _providersByPrefix.Values.ToArray(); - - public bool RegisterProvider(ILanguageModelProvider provider) - { - ArgumentNullException.ThrowIfNull(provider); - - if (string.IsNullOrWhiteSpace(provider.UrlPrefix)) - { - throw new ArgumentException("Provider must supply a URL prefix.", nameof(provider)); - } - - _providersByPrefix[provider.UrlPrefix] = provider; - return true; - } - - public ILanguageModelProvider? GetProviderFor(string? modelReference) - { - if (string.IsNullOrWhiteSpace(modelReference)) - { - return null; - } - - foreach (var provider in _providersByPrefix.Values) - { - if (modelReference.StartsWith(provider.UrlPrefix, StringComparison.OrdinalIgnoreCase)) - { - return provider; - } - } - - return null; - } - - public async Task> GetModelsAsync(bool refresh = false, CancellationToken cancellationToken = default) - { - List models = []; - - foreach (var provider in _providersByPrefix.Values) - { - cancellationToken.ThrowIfCancellationRequested(); - var providerModels = await provider.GetModelsAsync(refresh, cancellationToken).ConfigureAwait(false); - models.AddRange(providerModels); - } - - return models; - } - - public IChatClient? GetClient(ModelDetails model) - { - if (model is null) - { - return null; - } - - var reference = !string.IsNullOrWhiteSpace(model.Url) ? model.Url : model.Id; - return GetClient(reference); - } - - public IChatClient? GetClient(string? modelReference) - { - if (string.IsNullOrWhiteSpace(modelReference)) - { - return null; - } - - var provider = GetProviderFor(modelReference); - - return provider?.GetIChatClient(modelReference); - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs index 4b4148f995..a24032ff31 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs @@ -23,7 +23,7 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new FoundryLocalPasteProvider(config)); - private static readonly LanguageModelService LanguageModels = LanguageModelService.CreateDefault(); + private static readonly FoundryLocalModelProvider _modelProvider = FoundryLocalModelProvider.Instance; private readonly PasteAIConfig _config; @@ -72,11 +72,11 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider throw new PasteActionException( "No Foundry Local model selected", new InvalidOperationException("Model identifier is required"), - aiServiceMessage: "Please select a model in the AI provider settings. Model identifier should be in the format 'fl://model-name'."); + aiServiceMessage: "Please select a model in the AI provider settings."); } cancellationToken.ThrowIfCancellationRequested(); - var chatClient = LanguageModels.GetClient(modelReference); + var chatClient = _modelProvider.GetIChatClient(modelReference); if (chatClient is null) { throw new PasteActionException( @@ -85,9 +85,6 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider aiServiceMessage: "The model may not be downloaded or the Foundry Local service may not be running. Please check the model status in settings."); } - // Extract actual model ID from the URL (format: fl://modelId) - var actualModelId = modelReference.Replace("fl://", string.Empty).Trim('/'); - var userMessageContent = $""" User instructions: {prompt} @@ -104,7 +101,7 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider new(ChatRole.User, userMessageContent), }; - var chatOptions = CreateChatOptions(_config?.SystemPrompt, actualModelId); + var chatOptions = CreateChatOptions(_config?.SystemPrompt, modelReference); progress?.Report(0.1); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs index 79134b43ee..8812370f50 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs @@ -27,7 +27,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views public sealed partial class AdvancedPastePage : NavigablePage, IRefreshablePage, IDisposable { private readonly ObservableCollection _foundryCachedModels = new(); - private readonly ObservableCollection _foundryDownloadableModels = new(); private CancellationTokenSource _foundryModelLoadCts; private bool _suppressFoundrySelectionChanged; private bool _isFoundryLocalAvailable; @@ -57,7 +56,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views if (FoundryLocalPicker is not null) { FoundryLocalPicker.CachedModels = _foundryCachedModels; - FoundryLocalPicker.DownloadableModels = _foundryDownloadableModels; FoundryLocalPicker.SelectionChanged += FoundryLocalPicker_SelectionChanged; FoundryLocalPicker.LoadRequested += FoundryLocalPicker_LoadRequested; } @@ -469,7 +467,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views var cachedModels = cachedModelsEnumerable?.ToList() ?? new List(); - UpdateFoundryCollections(cachedModels, []); + UpdateFoundryCollections(cachedModels); ShowFoundryAvailableState(); RestoreFoundrySelection(cachedModels); } @@ -538,7 +536,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views UpdateFoundrySaveButtonState(); } - private void UpdateFoundryCollections(IReadOnlyCollection cachedModels, IReadOnlyCollection catalogModels) + private void UpdateFoundryCollections(IReadOnlyCollection cachedModels) { _foundryCachedModels.Clear(); @@ -547,20 +545,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views _foundryCachedModels.Add(model); } - var cachedReferences = new HashSet(_foundryCachedModels.Select(m => NormalizeFoundryModelReference(m.Url ?? m.Name)), StringComparer.OrdinalIgnoreCase); - - _foundryDownloadableModels.Clear(); - - foreach (var model in catalogModels.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) - { - var reference = NormalizeFoundryModelReference(model.Url ?? model.Name); - if (cachedReferences.Contains(reference)) - { - continue; - } - - _foundryDownloadableModels.Add(new FoundryDownloadableModel(model)); - } + var cachedReferences = new HashSet(_foundryCachedModels.Select(m => m.Name), StringComparer.OrdinalIgnoreCase); } private void RestoreFoundrySelection(IReadOnlyCollection cachedModels) @@ -576,9 +561,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views if (!string.IsNullOrWhiteSpace(currentModelReference)) { - var normalizedReference = NormalizeFoundryModelReference(currentModelReference); matchingModel = cachedModels.FirstOrDefault(model => - string.Equals(NormalizeFoundryModelReference(model.Url ?? model.Name), normalizedReference, StringComparison.OrdinalIgnoreCase)); + string.Equals(model.Name, currentModelReference, StringComparison.OrdinalIgnoreCase)); } if (FoundryLocalPicker is null) @@ -608,7 +592,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views { if (ViewModel?.PasteAIProviderDraft is not null) { - ViewModel.PasteAIProviderDraft.ModelName = NormalizeFoundryModelReference(matchingModel.Url ?? matchingModel.Name); + ViewModel.PasteAIProviderDraft.ModelName = matchingModel.Name; } if (FoundryLocalPicker is not null) @@ -620,19 +604,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views UpdateFoundrySaveButtonState(); } - private static string NormalizeFoundryModelReference(string modelReference) - { - if (string.IsNullOrWhiteSpace(modelReference)) - { - return string.Empty; - } - - var prefix = FoundryLocalModelProvider.Instance.UrlPrefix; - return modelReference.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - ? modelReference - : $"{prefix}{modelReference}"; - } - private void UpdateFoundrySaveButtonState() { if (PasteAIProviderConfigurationDialog is null) @@ -656,7 +627,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views return; } - if (!_isFoundryLocalAvailable || _foundryDownloadableModels.Any(model => model.IsDownloading)) + if (!_isFoundryLocalAvailable) { PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false; return; @@ -677,7 +648,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views { if (ViewModel?.PasteAIProviderDraft is not null) { - ViewModel.PasteAIProviderDraft.ModelName = NormalizeFoundryModelReference(selectedModel.Url ?? selectedModel.Name); + ViewModel.PasteAIProviderDraft.ModelName = selectedModel.Name; } if (FoundryLocalPicker is not null)