From 282f0a97f382309cc5bedeff921a5091b7c2b2ba Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 16 Mar 2026 11:55:27 -0700 Subject: [PATCH 1/4] Private catalog SDK support: AddCatalogAsync, SelectCatalogAsync, GetCatalogNamesAsync --- sdk/cs/src/Catalog.cs | 78 +++++++++++++++++++ sdk/cs/src/Detail/JsonSerializationContext.cs | 3 +- sdk/cs/src/ICatalog.cs | 33 +++++++- .../CatalogManagementTests.cs | 61 +++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index f33dcaff..38b44b06 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -249,4 +249,82 @@ public void Dispose() { _lock.Dispose(); } + + public async Task AddCatalogAsync(string name, Uri uri, string? clientId = null, + string? clientSecret = null, string? bearerToken = null, + string? tokenEndpoint = null, string? audience = null, + CancellationToken? ct = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(uri); + + await Utils.CallWithExceptionHandling(async () => + { + var request = new CoreInteropRequest + { + Params = new Dictionary + { + ["Name"] = name, + ["Uri"] = uri.ToString(), + ["ClientId"] = clientId ?? "", + ["ClientSecret"] = clientSecret ?? "", + ["BearerToken"] = bearerToken ?? "", + ["TokenEndpoint"] = tokenEndpoint ?? "", + ["Audience"] = audience ?? "" + } + }; + + var result = await _coreInterop.ExecuteCommandAsync("add_catalog", request, ct) + .ConfigureAwait(false); + if (result.Error != null) + { + throw new FoundryLocalException($"Error adding catalog '{name}': {result.Error}", _logger); + } + + // Force model list refresh to pick up new catalog's models + _lastFetch = DateTime.MinValue; + await UpdateModels(ct).ConfigureAwait(false); + }, $"Error adding catalog '{name}'.", _logger).ConfigureAwait(false); + } + + public async Task SelectCatalogAsync(string? catalogName, CancellationToken? ct = null) + { + await Utils.CallWithExceptionHandling(async () => + { + var request = new CoreInteropRequest + { + Params = new Dictionary + { + ["Name"] = catalogName ?? "" + } + }; + + var result = await _coreInterop.ExecuteCommandAsync("select_catalog", request, ct) + .ConfigureAwait(false); + if (result.Error != null) + { + throw new FoundryLocalException($"Error selecting catalog: {result.Error}", _logger); + } + + // Refresh model list to reflect the filter + _lastFetch = DateTime.MinValue; + await UpdateModels(ct).ConfigureAwait(false); + }, "Error selecting catalog.", _logger).ConfigureAwait(false); + } + + public async Task> GetCatalogNamesAsync(CancellationToken? ct = null) + { + return await Utils.CallWithExceptionHandling(async () => + { + CoreInteropRequest? input = null; + var result = await _coreInterop.ExecuteCommandAsync("get_catalog_names", input, ct) + .ConfigureAwait(false); + if (result.Error != null) + { + throw new FoundryLocalException($"Error getting catalog names: {result.Error}", _logger); + } + + return JsonSerializer.Deserialize(result.Data!, JsonSerializationContext.Default.ListString) ?? []; + }, "Error getting catalog names.", _logger).ConfigureAwait(false); + } } diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 37cc81ac..be57c5a6 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -39,6 +39,7 @@ namespace Microsoft.AI.Foundry.Local.Detail; // which has AOT-incompatible JsonConverters, so we only register the raw deserialization type) --- [JsonSerializable(typeof(LiveAudioTranscriptionRaw))] [JsonSerializable(typeof(CoreErrorResponse))] +[JsonSerializable(typeof(List))] // catalog names [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false)] internal partial class JsonSerializationContext : JsonSerializerContext diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 4dca8e7d..2e52b539 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -61,4 +61,35 @@ public interface ICatalog /// Optional CancellationToken. /// The latest version of the model. Will match the input if it is the latest version. Task GetLatestVersionAsync(IModel model, CancellationToken? ct = null); + + /// + /// Add a private model catalog. Models from the new catalog become available + /// on the next ListModelsAsync or GetModelAsync call. + /// + /// Display name for the catalog (e.g. "my-private-catalog"). + /// Base URL of the private catalog service. + /// Optional OAuth2 client credentials ID. + /// Optional OAuth2 client credentials secret, or API key for legacy auth. + /// Optional pre-obtained bearer token (for testing/self-service auth). + /// Optional OAuth2 token endpoint URL (e.g. "https://idp.example.com/oauth/token"). + /// Optional OAuth2 audience parameter (e.g. "model-distribution-service"). + /// Optional CancellationToken. + Task AddCatalogAsync(string name, Uri uri, string? clientId = null, string? clientSecret = null, + string? bearerToken = null, string? tokenEndpoint = null, string? audience = null, + CancellationToken? ct = null); + + /// + /// Filter the catalog to only return models from the named catalog. + /// Pass null to reset and show models from all catalogs. + /// + /// Catalog name to filter to, or null to show all. + /// Optional CancellationToken. + Task SelectCatalogAsync(string? catalogName, CancellationToken? ct = null); + + /// + /// Get the names of all registered catalogs. + /// + /// Optional CancellationToken. + /// List of catalog name strings. + Task> GetCatalogNamesAsync(CancellationToken? ct = null); } diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs new file mode 100644 index 00000000..81dc97f9 --- /dev/null +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs @@ -0,0 +1,61 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.Tests; + +using System.Text.Json; +using Microsoft.AI.Foundry.Local.Detail; +using Moq; + +public class CatalogManagementTests +{ + private static async Task CreateCatalogWithIntercepts( + List extra) + { + var logger = Utils.CreateCapturingLoggerMock([]); + var lm = new Mock(); + lm.Setup(m => m.ListLoadedModelsAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + + List intercepts = + [ + new() { CommandName = "get_catalog_name", ResponseData = "Test" }, + new() { CommandName = "get_model_list", + ResponseData = JsonSerializer.Serialize(Utils.TestCatalog.TestCatalog, + JsonSerializationContext.Default.ListModelInfo) }, + new() { CommandName = "get_cached_model_ids", ResponseData = "[]" }, + .. extra + ]; + + var ci = Utils.CreateCoreInteropWithIntercept(Utils.CoreInterop, intercepts); + return await Catalog.CreateAsync(lm.Object, ci.Object, logger.Object); + } + + [Test] + public async Task Test_AddAndSelectCatalog() + { + using var catalog = await CreateCatalogWithIntercepts( + [ + new() { CommandName = "add_catalog", ResponseData = "OK" }, + new() { CommandName = "select_catalog", ResponseData = "OK" } + ]); + + await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"), "id", "secret"); + await catalog.SelectCatalogAsync("priv"); + await catalog.SelectCatalogAsync(null); + await Assert.That(catalog).IsNotNull(); + } + + [Test] + public async Task Test_GetCatalogNames() + { + using var catalog = await CreateCatalogWithIntercepts( + [new() { CommandName = "get_catalog_names", ResponseData = "[\"public\",\"private\"]" }]); + + var names = await catalog.GetCatalogNamesAsync(); + await Assert.That(names.Count).IsEqualTo(2); + await Assert.That(names).Contains("private"); + } +} From ba4b05025e821a911297c413b3db77be9b3f1bdd Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Tue, 24 Mar 2026 14:54:54 -0700 Subject: [PATCH 2/4] fixing native errors --- sdk/cs/src/Catalog.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 38b44b06..6f7db205 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -306,7 +306,9 @@ await Utils.CallWithExceptionHandling(async () => throw new FoundryLocalException($"Error selecting catalog: {result.Error}", _logger); } - // Refresh model list to reflect the filter + // Force model list refresh so the managed-side maps reflect the filter. + // The native core already has models cached; this just re-fetches the + // (now-filtered) list into _modelAliasToModel / _modelIdToModelVariant. _lastFetch = DateTime.MinValue; await UpdateModels(ct).ConfigureAwait(false); }, "Error selecting catalog.", _logger).ConfigureAwait(false); From f2725a40e90cf8c70180baa7b575b67bf5d59dc2 Mon Sep 17 00:00:00 2001 From: kobby-kobbs Date: Mon, 6 Apr 2026 16:10:11 -0700 Subject: [PATCH 3/4] private catalog sdk improvements --- sdk/cs/src/Catalog.cs | 27 ++++++++++++++++++++------- sdk/cs/src/Detail/CoreInterop.cs | 3 +-- sdk/cs/src/ICatalog.cs | 4 ++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 6f7db205..59ebe677 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -190,10 +190,10 @@ private async Task GetLatestVersionImplAsync(IModel modelOrModelVariant, return latest.Id == modelOrModelVariant.Id ? modelOrModelVariant : latest; } - private async Task UpdateModels(CancellationToken? ct) + private async Task UpdateModels(CancellationToken? ct, bool forceRefresh = false) { // TODO: make this configurable - if (DateTime.Now - _lastFetch < TimeSpan.FromHours(6)) + if (!forceRefresh && DateTime.Now - _lastFetch < TimeSpan.FromHours(6)) { return; } @@ -258,6 +258,16 @@ public async Task AddCatalogAsync(string name, Uri uri, string? clientId = null, ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(uri); + if (uri.Scheme != "https" && uri.Scheme != "http") + { + throw new ArgumentException($"Catalog URI must use http or https scheme, got '{uri.Scheme}'.", nameof(uri)); + } + + if (tokenEndpoint != null && !Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var parsedEndpoint)) + { + throw new ArgumentException($"Token endpoint is not a valid URL: '{tokenEndpoint}'.", nameof(tokenEndpoint)); + } + await Utils.CallWithExceptionHandling(async () => { var request = new CoreInteropRequest @@ -282,13 +292,17 @@ await Utils.CallWithExceptionHandling(async () => } // Force model list refresh to pick up new catalog's models - _lastFetch = DateTime.MinValue; - await UpdateModels(ct).ConfigureAwait(false); + await UpdateModels(ct, forceRefresh: true).ConfigureAwait(false); }, $"Error adding catalog '{name}'.", _logger).ConfigureAwait(false); } public async Task SelectCatalogAsync(string? catalogName, CancellationToken? ct = null) { + if (catalogName != null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(catalogName); + } + await Utils.CallWithExceptionHandling(async () => { var request = new CoreInteropRequest @@ -309,8 +323,7 @@ await Utils.CallWithExceptionHandling(async () => // Force model list refresh so the managed-side maps reflect the filter. // The native core already has models cached; this just re-fetches the // (now-filtered) list into _modelAliasToModel / _modelIdToModelVariant. - _lastFetch = DateTime.MinValue; - await UpdateModels(ct).ConfigureAwait(false); + await UpdateModels(ct, forceRefresh: true).ConfigureAwait(false); }, "Error selecting catalog.", _logger).ConfigureAwait(false); } @@ -326,7 +339,7 @@ public async Task> GetCatalogNamesAsync(CancellationToken? ct = nul throw new FoundryLocalException($"Error getting catalog names: {result.Error}", _logger); } - return JsonSerializer.Deserialize(result.Data!, JsonSerializationContext.Default.ListString) ?? []; + return JsonSerializer.Deserialize(result.Data ?? "[]", JsonSerializationContext.Default.ListString) ?? []; }, "Error getting catalog names.", _logger).ConfigureAwait(false); } } diff --git a/sdk/cs/src/Detail/CoreInterop.cs b/sdk/cs/src/Detail/CoreInterop.cs index b88f5597..ff8e3cc3 100644 --- a/sdk/cs/src/Detail/CoreInterop.cs +++ b/sdk/cs/src/Detail/CoreInterop.cs @@ -324,7 +324,6 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, if (response.Error != IntPtr.Zero && response.ErrorLength > 0) { result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength)!; - _logger.LogDebug($"Input:{commandInput ?? "null"}"); _logger.LogDebug($"Command: {commandName} Error: {result.Error}"); } @@ -342,7 +341,7 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, } catch (Exception ex) when (ex is not OperationCanceledException) { - var msg = $"Error executing command '{commandName}' with input {commandInput ?? "null"}"; + var msg = $"Error executing command '{commandName}'"; throw new FoundryLocalException(msg, ex, _logger); } } diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 2e52b539..69e3ce8a 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -63,8 +63,8 @@ public interface ICatalog Task GetLatestVersionAsync(IModel model, CancellationToken? ct = null); /// - /// Add a private model catalog. Models from the new catalog become available - /// on the next ListModelsAsync or GetModelAsync call. + /// Add a private model catalog. The model list is refreshed automatically, + /// so models from the new catalog are available as soon as this call returns. /// /// Display name for the catalog (e.g. "my-private-catalog"). /// Base URL of the private catalog service. From b3ed6dbff2109c8df11a7ffed9702de65be31a15 Mon Sep 17 00:00:00 2001 From: kobby-kobbs Date: Mon, 6 Apr 2026 16:33:25 -0700 Subject: [PATCH 4/4] fixed comments --- sdk/cs/src/Catalog.cs | 11 +++++++++-- .../test/FoundryLocal.Tests/CatalogManagementTests.cs | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 59ebe677..44d0d450 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -263,9 +263,16 @@ public async Task AddCatalogAsync(string name, Uri uri, string? clientId = null, throw new ArgumentException($"Catalog URI must use http or https scheme, got '{uri.Scheme}'.", nameof(uri)); } - if (tokenEndpoint != null && !Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var parsedEndpoint)) + if (tokenEndpoint != null) { - throw new ArgumentException($"Token endpoint is not a valid URL: '{tokenEndpoint}'.", nameof(tokenEndpoint)); + if (!Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var parsedEndpoint)) + { + throw new ArgumentException($"Token endpoint is not a valid URL: '{tokenEndpoint}'.", nameof(tokenEndpoint)); + } + if (parsedEndpoint.Scheme != "https" && parsedEndpoint.Scheme != "http") + { + throw new ArgumentException($"Token endpoint must use http or https scheme, got '{parsedEndpoint.Scheme}'.", nameof(tokenEndpoint)); + } } await Utils.CallWithExceptionHandling(async () => diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs index 81dc97f9..2310c864 100644 --- a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs @@ -25,7 +25,7 @@ private static async Task CreateCatalogWithIntercepts( new() { CommandName = "get_model_list", ResponseData = JsonSerializer.Serialize(Utils.TestCatalog.TestCatalog, JsonSerializationContext.Default.ListModelInfo) }, - new() { CommandName = "get_cached_model_ids", ResponseData = "[]" }, + new() { CommandName = "get_cached_models", ResponseData = "[]" }, .. extra ];