From 282f0a97f382309cc5bedeff921a5091b7c2b2ba Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 16 Mar 2026 11:55:27 -0700 Subject: [PATCH 01/11] 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 f33dcaff5..38b44b062 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 37cc81ac8..be57c5a6f 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 4dca8e7d9..2e52b5394 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 000000000..81dc97f92 --- /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 02/11] 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 38b44b062..6f7db2053 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 03/11] 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 6f7db2053..59ebe677a 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 b88f55978..ff8e3cc36 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 2e52b5394..69e3ce8a5 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 04/11] 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 59ebe677a..44d0d4504 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 81dc97f92..2310c8645 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 ]; From 514a780d17b1e530a8ac2778e28c4a797a62571c Mon Sep 17 00:00:00 2001 From: kobby-kobbs Date: Tue, 14 Apr 2026 12:02:29 -0700 Subject: [PATCH 05/11] Address PR review: use InvalidateCache, move optional args to options map --- sdk/cs/src/Catalog.cs | 31 ++++++++++--------- sdk/cs/src/ICatalog.cs | 9 ++---- .../CatalogManagementTests.cs | 3 +- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 44d0d4504..7e1c2e59e 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, bool forceRefresh = false) + private async Task UpdateModels(CancellationToken? ct) { // TODO: make this configurable - if (!forceRefresh && DateTime.Now - _lastFetch < TimeSpan.FromHours(6)) + if (DateTime.Now - _lastFetch < TimeSpan.FromHours(6)) { return; } @@ -250,9 +250,8 @@ 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, + public async Task AddCatalogAsync(string name, Uri uri, + Dictionary? options = null, CancellationToken? ct = null) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -263,15 +262,15 @@ 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) + if (options != null && options.TryGetValue("TokenEndpoint", out var tokenEndpoint) && tokenEndpoint != null) { if (!Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var parsedEndpoint)) { - throw new ArgumentException($"Token endpoint is not a valid URL: '{tokenEndpoint}'.", nameof(tokenEndpoint)); + throw new ArgumentException($"Token endpoint is not a valid URL: '{tokenEndpoint}'."); } if (parsedEndpoint.Scheme != "https" && parsedEndpoint.Scheme != "http") { - throw new ArgumentException($"Token endpoint must use http or https scheme, got '{parsedEndpoint.Scheme}'.", nameof(tokenEndpoint)); + throw new ArgumentException($"Token endpoint must use http or https scheme, got '{parsedEndpoint.Scheme}'."); } } @@ -283,11 +282,11 @@ await Utils.CallWithExceptionHandling(async () => { ["Name"] = name, ["Uri"] = uri.ToString(), - ["ClientId"] = clientId ?? "", - ["ClientSecret"] = clientSecret ?? "", - ["BearerToken"] = bearerToken ?? "", - ["TokenEndpoint"] = tokenEndpoint ?? "", - ["Audience"] = audience ?? "" + ["ClientId"] = options?.GetValueOrDefault("ClientId") ?? "", + ["ClientSecret"] = options?.GetValueOrDefault("ClientSecret") ?? "", + ["BearerToken"] = options?.GetValueOrDefault("BearerToken") ?? "", + ["TokenEndpoint"] = options?.GetValueOrDefault("TokenEndpoint") ?? "", + ["Audience"] = options?.GetValueOrDefault("Audience") ?? "" } }; @@ -299,7 +298,8 @@ await Utils.CallWithExceptionHandling(async () => } // Force model list refresh to pick up new catalog's models - await UpdateModels(ct, forceRefresh: true).ConfigureAwait(false); + InvalidateCache(); + await UpdateModels(ct).ConfigureAwait(false); }, $"Error adding catalog '{name}'.", _logger).ConfigureAwait(false); } @@ -330,7 +330,8 @@ 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. - await UpdateModels(ct, forceRefresh: true).ConfigureAwait(false); + InvalidateCache(); + await UpdateModels(ct).ConfigureAwait(false); }, "Error selecting catalog.", _logger).ConfigureAwait(false); } diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 69e3ce8a5..0d15945ab 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -68,14 +68,9 @@ public interface ICatalog /// /// 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 authentication and configuration parameters (e.g. ClientId, ClientSecret, BearerToken, TokenEndpoint, Audience). /// Optional CancellationToken. - Task AddCatalogAsync(string name, Uri uri, string? clientId = null, string? clientSecret = null, - string? bearerToken = null, string? tokenEndpoint = null, string? audience = null, + Task AddCatalogAsync(string name, Uri uri, Dictionary? options = null, CancellationToken? ct = null); /// diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs index 2310c8645..156bcc70e 100644 --- a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs @@ -42,7 +42,8 @@ public async Task Test_AddAndSelectCatalog() new() { CommandName = "select_catalog", ResponseData = "OK" } ]); - await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"), "id", "secret"); + await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"), + new Dictionary { ["ClientId"] = "id", ["ClientSecret"] = "secret" }); await catalog.SelectCatalogAsync("priv"); await catalog.SelectCatalogAsync(null); await Assert.That(catalog).IsNotNull(); From 8456f35942ef5e0c0a3516770d9c0520b9253b1e Mon Sep 17 00:00:00 2001 From: kobby-kobbs Date: Mon, 20 Apr 2026 14:07:08 -0700 Subject: [PATCH 06/11] SDK: send Type in add_catalog; remove SelectCatalogAsync Aligns the C# SDK with the updated FoundryLocalCore native contract: - AddCatalogAsync now includes Type=AzurePrivate (overridable via options) in the add_catalog interop params, which the native dispatcher now requires. - Remove SelectCatalogAsync from ICatalog/Catalog and its unit test; the corresponding select_catalog handler was removed from the native layer. Callers can re-introduce per-catalog filtering when an Id-clash scenario becomes real. --- sdk/cs/src/Catalog.cs | 55 ++++--------------- sdk/cs/src/ICatalog.cs | 10 +--- .../CatalogManagementTests.cs | 7 +-- 3 files changed, 15 insertions(+), 57 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 7e1c2e59e..4c4ac74be 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -276,19 +276,20 @@ public async Task AddCatalogAsync(string name, Uri uri, await Utils.CallWithExceptionHandling(async () => { - var request = new CoreInteropRequest + // Start from caller-supplied options, then overlay Name/Uri/Type so they + // can't be silently overridden via options. Callers can still pass + // "Type" in options to target a non-default catalog implementation; + // the explicit assignment below honours that when present. + var p = new Dictionary(options ?? new Dictionary()) { - Params = new Dictionary - { - ["Name"] = name, - ["Uri"] = uri.ToString(), - ["ClientId"] = options?.GetValueOrDefault("ClientId") ?? "", - ["ClientSecret"] = options?.GetValueOrDefault("ClientSecret") ?? "", - ["BearerToken"] = options?.GetValueOrDefault("BearerToken") ?? "", - ["TokenEndpoint"] = options?.GetValueOrDefault("TokenEndpoint") ?? "", - ["Audience"] = options?.GetValueOrDefault("Audience") ?? "" - } + ["Name"] = name, + ["Uri"] = uri.ToString(), }; + if (!p.ContainsKey("Type") || string.IsNullOrEmpty(p["Type"])) + { + p["Type"] = "AzurePrivate"; + } + var request = new CoreInteropRequest { Params = p }; var result = await _coreInterop.ExecuteCommandAsync("add_catalog", request, ct) .ConfigureAwait(false); @@ -303,38 +304,6 @@ await Utils.CallWithExceptionHandling(async () => }, $"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 - { - 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); - } - - // 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. - InvalidateCache(); - await UpdateModels(ct).ConfigureAwait(false); - }, "Error selecting catalog.", _logger).ConfigureAwait(false); - } - public async Task> GetCatalogNamesAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(async () => diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 0d15945ab..4ad5b13ec 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -68,19 +68,11 @@ public interface ICatalog /// /// Display name for the catalog (e.g. "my-private-catalog"). /// Base URL of the private catalog service. - /// Optional authentication and configuration parameters (e.g. ClientId, ClientSecret, BearerToken, TokenEndpoint, Audience). + /// Optional authentication and configuration parameters (e.g. ClientId, ClientSecret, BearerToken, TokenEndpoint, Audience). Pass "Type" to override the default catalog type ("AzurePrivate"). /// Optional CancellationToken. Task AddCatalogAsync(string name, Uri uri, Dictionary? options = 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. /// diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs index 156bcc70e..7858f317d 100644 --- a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs @@ -34,18 +34,15 @@ .. extra } [Test] - public async Task Test_AddAndSelectCatalog() + public async Task Test_AddCatalog() { using var catalog = await CreateCatalogWithIntercepts( [ - new() { CommandName = "add_catalog", ResponseData = "OK" }, - new() { CommandName = "select_catalog", ResponseData = "OK" } + new() { CommandName = "add_catalog", ResponseData = "OK" } ]); await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"), new Dictionary { ["ClientId"] = "id", ["ClientSecret"] = "secret" }); - await catalog.SelectCatalogAsync("priv"); - await catalog.SelectCatalogAsync(null); await Assert.That(catalog).IsNotNull(); } From 2f6b743d06a0456a147afa8f07978bc95167e7c8 Mon Sep 17 00:00:00 2001 From: kobby-kobbs Date: Wed, 22 Apr 2026 10:03:15 -0700 Subject: [PATCH 07/11] replaced containsKey with TryGetValue --- sdk/cs/src/Catalog.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 4c4ac74be..e0d3400db 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -285,7 +285,7 @@ await Utils.CallWithExceptionHandling(async () => ["Name"] = name, ["Uri"] = uri.ToString(), }; - if (!p.ContainsKey("Type") || string.IsNullOrEmpty(p["Type"])) + if (!p.TryGetValue("Type", out var typeValue) || string.IsNullOrEmpty(typeValue)) { p["Type"] = "AzurePrivate"; } From c5503644f716374e4d78a19a4ae71ac707201cd4 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Thu, 23 Apr 2026 01:42:48 -0700 Subject: [PATCH 08/11] samples(cs): add private-catalog sample End-to-end C# sample demonstrating ICatalog.AddCatalogAsync: - signs an RS256 JWT from a customer private key - registers a private MDS-backed catalog at runtime - lists public + private models, partitioned by registry Uri - downloads and streams chat with the selected model Falls back to public-only if AddCatalogAsync is unavailable. --- samples/cs/README.md | 1 + .../cs/private-catalog/PrivateCatalog.csproj | 72 +++++ samples/cs/private-catalog/PrivateCatalog.sln | 34 +++ samples/cs/private-catalog/Program.cs | 254 ++++++++++++++++++ samples/cs/private-catalog/README.md | 81 ++++++ samples/cs/private-catalog/appsettings.json | 5 + 6 files changed, 447 insertions(+) create mode 100644 samples/cs/private-catalog/PrivateCatalog.csproj create mode 100644 samples/cs/private-catalog/PrivateCatalog.sln create mode 100644 samples/cs/private-catalog/Program.cs create mode 100644 samples/cs/private-catalog/README.md create mode 100644 samples/cs/private-catalog/appsettings.json diff --git a/samples/cs/README.md b/samples/cs/README.md index ad10a3c65..9207fe400 100644 --- a/samples/cs/README.md +++ b/samples/cs/README.md @@ -18,6 +18,7 @@ Both packages provide the same APIs, so the same source code works on all platfo | [tool-calling-foundry-local-sdk](tool-calling-foundry-local-sdk/) | Use tool calling with native chat completions. | | [tool-calling-foundry-local-web-server](tool-calling-foundry-local-web-server/) | Use tool calling with the local web server. | | [model-management-example](model-management-example/) | Manage models, variant selection, and updates. | +| [private-catalog](private-catalog/) | Register a private MDS-backed catalog with `AddCatalogAsync`, list public + private models, and chat with one. | | [tutorial-chat-assistant](tutorial-chat-assistant/) | Build an interactive chat assistant (tutorial). | | [tutorial-document-summarizer](tutorial-document-summarizer/) | Summarize documents with AI (tutorial). | | [tutorial-tool-calling](tutorial-tool-calling/) | Create a tool-calling assistant (tutorial). | diff --git a/samples/cs/private-catalog/PrivateCatalog.csproj b/samples/cs/private-catalog/PrivateCatalog.csproj new file mode 100644 index 000000000..a49711c26 --- /dev/null +++ b/samples/cs/private-catalog/PrivateCatalog.csproj @@ -0,0 +1,72 @@ + + + + Exe + enable + enable + + + + + net9.0-windows10.0.26100 + false + ARM64;x64 + None + false + + + + + net9.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + $(MSBuildThisFileDirectory)..\..\..\..\neutron-server\artifacts\bin\Core\debug_net9.0_win-x64\Microsoft.AI.Foundry.Local.Core.dll + + + + + + <_OverrideDest Include="$(OutputPath)" /> + <_OverrideDest Include="$(OutputPath)$(RuntimeIdentifier)\" Condition="'$(RuntimeIdentifier)' != ''" /> + <_OverrideDest Include="$(PublishDir)" Condition="'$(PublishDir)' != ''" /> + + + + + diff --git a/samples/cs/private-catalog/PrivateCatalog.sln b/samples/cs/private-catalog/PrivateCatalog.sln new file mode 100644 index 000000000..6d66e4fa5 --- /dev/null +++ b/samples/cs/private-catalog/PrivateCatalog.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivateCatalog", "PrivateCatalog.csproj", "{B1C23D45-6789-4ABC-DEF0-123456789ABC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|Any CPU.Build.0 = Debug|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x64.ActiveCfg = Debug|x64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x64.Build.0 = Debug|x64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x86.ActiveCfg = Debug|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x86.Build.0 = Debug|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|Any CPU.ActiveCfg = Release|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|Any CPU.Build.0 = Release|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x64.ActiveCfg = Release|x64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x64.Build.0 = Release|x64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x86.ActiveCfg = Release|ARM64 + {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs new file mode 100644 index 000000000..40f1aeca7 --- /dev/null +++ b/samples/cs/private-catalog/Program.cs @@ -0,0 +1,254 @@ +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +// --------------------------------------------------------------------------- +// Private Catalog sample — registers a customer MDS catalog with a self-signed +// JWT, lists models (public + private), lets you pick one, and runs a streaming +// chat completion. +// +// Usage: +// PrivateCatalog (interactive — pick from list) +// PrivateCatalog --model phi-4 (pick by alias) +// PrivateCatalog --model Phi-4-generic-cpu:1 (pick by exact variant id) +// PrivateCatalog --list (list models and exit) +// PrivateCatalog --customer cust2 (override MdsCustomer) +// PrivateCatalog --prompt "Hello!" (custom prompt) +// --------------------------------------------------------------------------- +string? cliModel = null; +string cliPrompt = "Why is the sky blue?"; +bool listOnly = false; +string? cliCustomer = null; + +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "-m": + case "--model": + if (i + 1 < args.Length) cliModel = args[++i]; + else { Console.WriteLine("Error: --model requires a value."); return; } + break; + case "-p": + case "--prompt": + if (i + 1 < args.Length) cliPrompt = args[++i]; + else { Console.WriteLine("Error: --prompt requires a value."); return; } + break; + case "-c": + case "--customer": + if (i + 1 < args.Length) cliCustomer = args[++i]; + else { Console.WriteLine("Error: --customer requires a value."); return; } + break; + case "-l": + case "--list": + listOnly = true; + break; + case "-h": + case "--help": + Console.WriteLine("Usage: PrivateCatalog [options]"); + Console.WriteLine(" -m, --model Model alias or variant id"); + Console.WriteLine(" -c, --customer Customer name (default: from appsettings)"); + Console.WriteLine(" -p, --prompt Prompt (default: \"Why is the sky blue?\")"); + Console.WriteLine(" -l, --list List models and exit"); + return; + } +} + +CancellationToken ct = default; + +// --- Load config --- +var settings = JsonDocument.Parse( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))).RootElement; +var mdsHost = settings.GetProperty("MdsHost").GetString()!; +var mdsCustomer = cliCustomer ?? settings.GetProperty("MdsCustomer").GetString()!; +var mdsKeyDir = settings.GetProperty("MdsKeyDir").GetString()!; + +// --- Derive customer resources (same convention as mds/scripts/download_model.py) --- +var safeName = mdsCustomer.ToLower().Replace(" ", "").Replace("-", ""); +var registryName = $"mds-{mdsCustomer.ToLower()}-registry"; +var issuer = $"https://mds{safeName}jwks.blob.core.windows.net/jwks"; +var kid = $"mds-{mdsCustomer.ToLower()}-key-1"; +var keyPath = Path.Combine(mdsKeyDir, $"{mdsCustomer.ToLower()}-key.pem"); + +if (!File.Exists(keyPath)) +{ + Console.WriteLine($"Error: Private key not found at {keyPath}"); + Console.WriteLine("Run mds/scripts/create_jwks_storage.py --customer first."); + return; +} + +var jwt = SignJwt(keyPath, kid, issuer, registryName); +Console.WriteLine($"Signed JWT for '{mdsCustomer}' (registry={registryName})"); + +// --- Init Foundry Local --- +await FoundryLocalManager.CreateAsync( + new Configuration { AppName = "private_catalog_sample", LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information }, + Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; +Console.WriteLine("Registering execution providers..."); +await mgr.DownloadAndRegisterEpsAsync(); +Console.WriteLine("Done."); + +// --- Register private catalog (falls back to public-only if it fails) --- +var catalog = await mgr.GetCatalogAsync(); + +Console.WriteLine($"\nRegistering private catalog at {mdsHost}..."); +bool privateRegistered = false; +try +{ + await catalog.AddCatalogAsync("private", new Uri(mdsHost), + options: new Dictionary + { + ["BearerToken"] = jwt, + ["Audience"] = "model-distribution-service", + }); + privateRegistered = true; + Console.WriteLine("Private catalog registered."); +} +catch (Exception ex) +{ + Console.WriteLine($"Warning: could not register private catalog ({ex.Message})."); + Console.WriteLine("Continuing with the public catalog only."); +} + +// --- List models (grouped by origin) --- +// Classify by the model's Uri: private MDS models have an +// `azureml://registries//...` Uri, public ones point to the +// built-in Azure ML registry. This is robust to neutron persisting +// registered catalogs across runs (which would break a pre-snapshot approach). +var allModels = await catalog.ListModelsAsync(); +var allVariants = allModels.SelectMany(m => m.Variants).ToList(); + +bool IsPrivate(IModel v) => + v.Info.Uri?.Contains(registryName, StringComparison.OrdinalIgnoreCase) == true; + +var publicVariants = allVariants.Where(v => !IsPrivate(v)).ToList(); +var privateVariants = allVariants.Where(IsPrivate).ToList(); + +// Rebuild in display order (public first, then private) so numbered selection +// in the interactive picker maps 1:1 to what's printed. +allVariants = publicVariants.Concat(privateVariants).ToList(); + +int idx = 0; +Console.WriteLine($"\n=== Public Models ({publicVariants.Count}) ==="); +foreach (var v in publicVariants) + Console.WriteLine($" [{++idx}] {v.Alias} ({v.Id})"); + +if (privateRegistered) +{ + Console.WriteLine($"\n=== Private Models ({privateVariants.Count}) ==="); + if (privateVariants.Count == 0) + Console.WriteLine(" (none)"); + foreach (var v in privateVariants) + Console.WriteLine($" [{++idx}] {v.Alias} ({v.Id})"); +} + +if (listOnly) return; + +// --- Resolve a model (from --model or interactive prompt) --- +IModel? model = null; +string? input = cliModel; + +if (string.IsNullOrWhiteSpace(input)) +{ + Console.Write("\nEnter model number, alias, or variant id (q to quit): "); + input = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(input) || input.Equals("q", StringComparison.OrdinalIgnoreCase)) return; + + if (int.TryParse(input, out int n) && n >= 1 && n <= allVariants.Count) + input = allVariants[n - 1].Id; +} + +model = await ResolveModel(catalog, allVariants, input!); +if (model == null) +{ + Console.WriteLine($"\nModel '{input}' not found."); + return; +} +Console.WriteLine($"\nSelected: {model.Id}"); + +// --- Download / load / chat --- +await model.DownloadAsync(p => +{ + Console.Write($"\rDownloading: {p:F1}%"); + if (p >= 100f) Console.WriteLine(); +}); + +Console.Write($"Loading {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine(" done."); + +var chat = await model.GetChatClientAsync(); +var messages = new List { new() { Role = "user", Content = cliPrompt } }; + +Console.WriteLine("Chat completion:"); +await foreach (var chunk in chat.CompleteChatStreamingAsync(messages, ct)) +{ + Console.Write(chunk.Choices[0].Message.Content); + Console.Out.Flush(); +} +Console.WriteLine(); + +await model.UnloadAsync(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static async Task ResolveModel( + ICatalog catalog, List allVariants, string input) +{ + // Exact variant id + var model = await catalog.GetModelVariantAsync(input); + if (model != null) return model; + + // Alias (prefer generic-cpu variant) + var resolved = await catalog.GetModelAsync(input); + if (resolved != null) + { + var pick = resolved.Variants.FirstOrDefault(v => + v.Id.Contains("generic-cpu", StringComparison.OrdinalIgnoreCase)) + ?? resolved.Variants[0]; + return await catalog.GetModelVariantAsync(pick.Id); + } + + // Substring match against the combined list + var match = allVariants.FirstOrDefault(v => + v.Id.Contains(input, StringComparison.OrdinalIgnoreCase) || + v.Alias.Contains(input, StringComparison.OrdinalIgnoreCase)); + return match != null ? await catalog.GetModelVariantAsync(match.Id) : null; +} + +static string SignJwt(string pemPath, string kid, string issuer, string registryName) +{ + using var rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(pemPath)); + + var now = DateTimeOffset.UtcNow; + var header = JsonSerializer.Serialize(new { alg = "RS256", typ = "JWT", kid }); + var payload = JsonSerializer.Serialize(new Dictionary + { + ["iss"] = issuer, + ["sub"] = "foundry-local-sample", + ["aud"] = "model-distribution-service", + ["iat"] = now.ToUnixTimeSeconds(), + ["exp"] = now.AddHours(1).ToUnixTimeSeconds(), + ["registry_name"] = registryName, + ["entitlements"] = new Dictionary + { + ["models"] = new[] { "*" }, + ["versions"] = new[] { "*" }, + }, + }); + + var h = B64Url(Encoding.UTF8.GetBytes(header)); + var p = B64Url(Encoding.UTF8.GetBytes(payload)); + var sig = rsa.SignData(Encoding.UTF8.GetBytes($"{h}.{p}"), + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return $"{h}.{p}.{B64Url(sig)}"; +} + +static string B64Url(byte[] data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); diff --git a/samples/cs/private-catalog/README.md b/samples/cs/private-catalog/README.md new file mode 100644 index 000000000..1e110123a --- /dev/null +++ b/samples/cs/private-catalog/README.md @@ -0,0 +1,81 @@ +# Private Catalog (C#) + +End-to-end sample: register a customer MDS catalog with Foundry Local using a +self-signed RS256 JWT, list public + private models, download one, and run a +streaming chat completion. + +## Prerequisites + +- .NET 9 SDK +- Windows x64 (other RIDs work if you adjust `-r`) +- A customer provisioned in MDS (registry + JWKS). See + [mds/docs/CUSTOMER_ONBOARDING.md](../../../../mds/docs/CUSTOMER_ONBOARDING.md). +- The customer's **private key** (`-key.pem`) available locally. + The matching JWKS must already be published at + `https://mdsjwks.blob.core.windows.net/jwks/.well-known/jwks.json`. +- A running Foundry Local (`neutron`) that supports `AddCatalogAsync`. + If it doesn't, the sample falls back to the public catalog only. + +## Configure + +Edit [appsettings.json](appsettings.json): + +```json +{ + "MdsHost": "https://mds-web-app-staging.azurewebsites.net", + "MdsCustomer": "emmanueltest1", + "MdsKeyDir": "C:/Users/eassumang/work/mds/scripts" +} +``` + +- `MdsHost` — MDS endpoint (staging or prod). +- `MdsCustomer` — customer name. Used to derive the registry + (`mds--registry`), JWKS URL, and key file name. +- `MdsKeyDir` — folder containing `-key.pem`. + +## Build + +From this folder: + +```powershell +dotnet build .\PrivateCatalog.csproj -r win-x64 +``` + +> **Do not use `dotnet run`.** It rewrites DLLs in the output folder and +> breaks the private-catalog registration path in the copied neutron binaries. +> Always launch the `.exe` directly. + +## Run + +```powershell +.\bin\Debug\net9.0-windows10.0.26100\win-x64\PrivateCatalog.exe +``` + + + +## What it does + +1. Loads `appsettings.json` and derives the customer's registry, issuer, and + key path. +2. Signs an RS256 JWT with claims: + `iss`, `sub`, `aud=model-distribution-service`, `iat`, `exp`, + `registry_name`, `entitlements={models:["*"], versions:["*"]}`. +3. Initializes Foundry Local and registers execution providers. +4. Calls `catalog.AddCatalogAsync("private", mdsHost, { BearerToken, Audience })`. + If it fails (e.g. older neutron without this API), falls back to public-only. +5. Lists all models, partitioned by `Uri`: + - **Public**: built-in Azure ML registry + - **Private**: `azureml://registries/mds--registry/...` +6. Prompts you to pick one, downloads it, loads it, and streams a chat + completion. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `Private key not found at ...` | `MdsKeyDir` or customer name wrong | Check [appsettings.json](appsettings.json); ensure `-key.pem` exists | +| `Warning: could not register private catalog (Unknown command)` | Neutron build predates `AddCatalogAsync` | Use a newer neutron; sample continues with public-only | +| `401 Invalid token issuer` | JWKS not yet published, or wrong issuer URL | Verify `https://mdsjwks.blob.core.windows.net/jwks/.well-known/jwks.json` returns your key | +| Private model appears in **Public** section | Model's registry Uri is `local://...` | Re-upload with `mds/scripts/upload_model.py` so registry stores proper blob info | +| `Failed to download model` | Same as above, or SAS generation error | Check MDS logs; confirm `blob_prefix` tag on the registry entry | +| `dotnet run` seems to break things | It does — see note above | Run `.\...\PrivateCatalog.exe` directly | diff --git a/samples/cs/private-catalog/appsettings.json b/samples/cs/private-catalog/appsettings.json new file mode 100644 index 000000000..aab3dbb29 --- /dev/null +++ b/samples/cs/private-catalog/appsettings.json @@ -0,0 +1,5 @@ +{ + "MdsHost": "https://mds-model-distribution.azurewebsites.net", + "MdsCustomer": "your-customer-name", + "MdsKeyDir": "./keys" +} From cd63f353f98476a680e08d53b60cd007fdaeb0b1 Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Thu, 30 Apr 2026 16:05:00 -0700 Subject: [PATCH 09/11] Add ORT-Nightly package source to nuget.config --- samples/cs/nuget.config | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/samples/cs/nuget.config b/samples/cs/nuget.config index 3a9f6b327..de71f66d5 100644 --- a/samples/cs/nuget.config +++ b/samples/cs/nuget.config @@ -3,5 +3,6 @@ + - \ No newline at end of file + From f53975ac4a116cf6860aade7d993623c3351d5eb Mon Sep 17 00:00:00 2001 From: Baiju Meswani Date: Thu, 30 Apr 2026 18:55:04 -0700 Subject: [PATCH 10/11] Remove ORT-Nightly package source from nuget.config --- samples/cs/nuget.config | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/cs/nuget.config b/samples/cs/nuget.config index de71f66d5..765346e53 100644 --- a/samples/cs/nuget.config +++ b/samples/cs/nuget.config @@ -3,6 +3,5 @@ - From 0e6b4130748bcea8be92cdd10e1fffe1aa1e6aeb Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Fri, 1 May 2026 01:13:38 -0700 Subject: [PATCH 11/11] Fix stale onboarding script reference in error message --- samples/cs/private-catalog/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs index 7357f1201..47eb7a3bc 100644 --- a/samples/cs/private-catalog/Program.cs +++ b/samples/cs/private-catalog/Program.cs @@ -75,7 +75,7 @@ if (!File.Exists(keyPath)) { Console.WriteLine($"Error: Private key not found at {keyPath}"); - Console.WriteLine("Run mds/scripts/create_jwks_storage.py --customer first."); + Console.WriteLine("Run mds/scripts/onboard.py --customer --test-keys first."); return; }