From 282f0a97f382309cc5bedeff921a5091b7c2b2ba Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 16 Mar 2026 11:55:27 -0700 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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; } From f3361ca15bb0f7e4807f38283f80ba8ef4522195 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 11 May 2026 16:17:00 -0700 Subject: [PATCH 12/19] fix(sdk/cs): netstandard2.0 build break in AddCatalogAsync ArgumentException.ThrowIfNullOrWhiteSpace / ArgumentNullException.ThrowIfNull are net7+ only. --- sdk/cs/src/Catalog.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index e0d3400db..707b9f333 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -254,8 +254,19 @@ public async Task AddCatalogAsync(string name, Uri uri, Dictionary? options = null, CancellationToken? ct = null) { +#if NET7_0_OR_GREATER ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(uri); +#else + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Catalog name must be a non-empty, non-whitespace string.", nameof(name)); + } + if (uri is null) + { + throw new ArgumentNullException(nameof(uri)); + } +#endif if (uri.Scheme != "https" && uri.Scheme != "http") { From f05c3f05131671946dd8bb1c20ec6b247c29c961 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 11 May 2026 16:17:23 -0700 Subject: [PATCH 13/19] fix(sdk/cs): make ILogger optional in CreateAsync (W1) Defaults logger to NullLogger.Instance when null/omitted. Avoids NullReferenceException for callers that don't provide a logger. --- sdk/cs/src/FoundryLocalManager.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index b014850f6..687ca58f3 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -11,6 +11,7 @@ namespace Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; public class FoundryLocalManager : IDisposable { @@ -46,15 +47,17 @@ public class FoundryLocalManager : IDisposable /// Create the singleton instance. /// /// Configuration to use. - /// Application logger to use. - /// Use Microsoft.Extensions.Logging.NullLogger.Instance if you wish to ignore log output from the SDK. + /// Application logger to use. If null, log output from the SDK is + /// discarded (equivalent to passing ). /// /// Optional cancellation token for the initialization. /// Task creating the instance. /// - public static async Task CreateAsync(Configuration configuration, ILogger logger, + public static async Task CreateAsync(Configuration configuration, ILogger? logger = null, CancellationToken? ct = null) { + logger ??= NullLogger.Instance; + using var disposable = await asyncLock.LockAsync().ConfigureAwait(false); if (instance != null) From ebf11afdf4bde844faedd6795fa0910f734acfcf Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 11 May 2026 18:43:26 -0700 Subject: [PATCH 14/19] fix(sdk/cs): bug-bash private catalog improvements --- .../cs/private-catalog/PrivateCatalog.csproj | 20 +++- samples/cs/private-catalog/Program.cs | 42 ++++--- sdk/cs/src/Catalog.cs | 108 +++++++++++++++--- sdk/cs/src/Detail/ModelLoadManager.cs | 6 +- sdk/cs/src/Detail/ModelVariant.cs | 64 ++++++++--- sdk/cs/src/FoundryModelInfo.cs | 27 +++++ sdk/cs/src/ICatalog.cs | 50 ++++++++ sdk/cs/src/Utils.cs | 32 ++++++ 8 files changed, 301 insertions(+), 48 deletions(-) diff --git a/samples/cs/private-catalog/PrivateCatalog.csproj b/samples/cs/private-catalog/PrivateCatalog.csproj index 7ad97c28f..48226f128 100644 --- a/samples/cs/private-catalog/PrivateCatalog.csproj +++ b/samples/cs/private-catalog/PrivateCatalog.csproj @@ -4,6 +4,15 @@ Exe enable enable + + + $(NoWarn);NU1604;NU1701 @@ -34,10 +43,15 @@ - + - - + + diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs index 47eb7a3bc..214c4307c 100644 --- a/samples/cs/private-catalog/Program.cs +++ b/samples/cs/private-catalog/Program.cs @@ -66,11 +66,14 @@ 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"; +// Use invariant casing for identifier-like values so behaviour doesn't change on +// machines with non-en-US current culture (e.g. Turkish 'I' folding). +var customerLower = mdsCustomer.ToLowerInvariant(); +var safeName = customerLower.Replace(" ", "").Replace("-", ""); +var registryName = $"mds-{customerLower}-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"); +var kid = $"mds-{customerLower}-key-1"; +var keyPath = Path.Combine(mdsKeyDir, $"{customerLower}-key.pem"); if (!File.Exists(keyPath)) { @@ -83,6 +86,10 @@ Console.WriteLine($"Signed JWT for '{mdsCustomer}' (registry={registryName})"); // --- Init Foundry Local --- +// NOTE: the `logger` argument is optional. Pass `null` (or omit it) to silence +// SDK logging — the SDK defaults to NullLogger.Instance. The sample wires up a +// real ILogger via Utils.GetAppLogger() so download / catalog progress shows +// up in the console. await FoundryLocalManager.CreateAsync( new Configuration { AppName = "private_catalog_sample", LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information }, Utils.GetAppLogger()); @@ -95,7 +102,10 @@ await FoundryLocalManager.CreateAsync( bool privateRegistered = false; try { - await catalog.AddCatalogAsync("private", new Uri(mdsHost), + // AddOrUpdateCatalogAsync is idempotent: safe to call when neutron has + // persisted the catalog from a previous run, and lets callers refresh + // credentials (rotate BearerToken) without restarting the SDK. + await catalog.AddOrUpdateCatalogAsync("private", new Uri(mdsHost), options: new Dictionary { ["BearerToken"] = jwt, @@ -104,22 +114,20 @@ await FoundryLocalManager.CreateAsync( privateRegistered = true; Console.WriteLine("Private catalog registered."); } -catch (Exception ex) +catch (Exception ex) when (ex is not OperationCanceledException) { 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). +// Use ModelInfo.IsFromCatalogRegistry (SDK helper) instead of string-matching +// the Uri ourselves — robust to URI shape changes and to neutron persisting +// registered catalogs across runs. 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; +bool IsPrivate(IModel v) => v.Info.IsFromCatalogRegistry(registryName); var publicVariants = allVariants.Where(v => !IsPrivate(v)).ToList(); var privateVariants = allVariants.Where(IsPrivate).ToList(); @@ -158,7 +166,7 @@ bool IsPrivate(IModel v) => input = allVariants[n - 1].Id; } -model = await ResolveModel(catalog, allVariants, input!); +model = await ResolveModel(catalog, allVariants, input!, privateRegistered ? registryName : null); if (model == null) { Console.WriteLine($"\nModel '{input}' not found."); @@ -195,14 +203,16 @@ await model.DownloadAsync(p => // --------------------------------------------------------------------------- static async Task ResolveModel( - ICatalog catalog, List allVariants, string input) + ICatalog catalog, List allVariants, string input, string? preferRegistry) { // 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); + // Alias — if a private catalog is registered prefer its variants so the + // same alias resolves to the private build over the public one. Falls back + // to the unfiltered model when no private variant matches. + var resolved = await catalog.GetModelAsync(input, preferRegistry); if (resolved != null) { var pick = resolved.Variants.FirstOrDefault(v => diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 707b9f333..3ad3007c7 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -58,6 +58,20 @@ public async Task> ListModelsAsync(CancellationToken? ct = null) "Error listing models.", _logger).ConfigureAwait(false); } + public async Task> ListModelsAsync(string catalogRegistryName, CancellationToken? ct = null) + { + if (string.IsNullOrWhiteSpace(catalogRegistryName)) + { + throw new ArgumentException("Catalog registry name must be a non-empty string.", nameof(catalogRegistryName)); + } + var all = await ListModelsAsync(ct).ConfigureAwait(false); + // Match Model itself or any variant: ModelInfo merges variants per alias, + // so the variant check is needed for public+private alias collisions. + return all.Where(m => m.Info.IsFromCatalogRegistry(catalogRegistryName) || + m.Variants.Any(v => v.Info.IsFromCatalogRegistry(catalogRegistryName))) + .ToList(); + } + public async Task> GetCachedModelsAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => GetCachedModelsImplAsync(ct), @@ -77,6 +91,30 @@ public async Task> GetLoadedModelsAsync(CancellationToken? ct = nul .ConfigureAwait(false); } + public async Task GetModelAsync(string modelAlias, + string? preferCatalogRegistryName, + CancellationToken? ct = null) + { + if (string.IsNullOrWhiteSpace(preferCatalogRegistryName)) + { + return await GetModelAsync(modelAlias, ct).ConfigureAwait(false); + } + + var model = await GetModelAsync(modelAlias, ct).ConfigureAwait(false); + if (model == null || model.Info.IsFromCatalogRegistry(preferCatalogRegistryName)) + { + return model; + } + + // Prefer a variant from the named registry; pin it via GetModelVariantAsync + // so callers get the single-variant IModel contract. Fall back to the + // unfiltered model so the alias still resolves — preference is best-effort. + var preferred = model.Variants.FirstOrDefault(v => v.Info.IsFromCatalogRegistry(preferCatalogRegistryName)); + return preferred != null + ? await GetModelVariantAsync(preferred.Id, ct).ConfigureAwait(false) ?? model + : model; + } + public async Task GetModelVariantAsync(string modelId, CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => GetModelVariantImplAsync(modelId, ct), @@ -250,9 +288,14 @@ public void Dispose() _lock.Dispose(); } - public async Task AddCatalogAsync(string name, Uri uri, - Dictionary? options = null, - CancellationToken? ct = null) + public Task AddCatalogAsync(string name, Uri uri, + Dictionary? options = null, + CancellationToken? ct = null) + => AddOrUpdateCatalogAsync(name, uri, options, ct); + + public async Task AddOrUpdateCatalogAsync(string name, Uri uri, + Dictionary? options = null, + CancellationToken? ct = null) { #if NET7_0_OR_GREATER ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -287,10 +330,9 @@ public async Task AddCatalogAsync(string name, Uri uri, await Utils.CallWithExceptionHandling(async () => { - // 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. + // Caller-supplied options first; Name/Uri/Type overlaid so they can't + // be silently overridden. Default Type to AzurePrivate; honour an + // explicit "Type" in options. var p = new Dictionary(options ?? new Dictionary()) { ["Name"] = name, @@ -300,21 +342,61 @@ await Utils.CallWithExceptionHandling(async () => { p["Type"] = "AzurePrivate"; } - var request = new CoreInteropRequest { Params = p }; - var result = await _coreInterop.ExecuteCommandAsync("add_catalog", request, ct) - .ConfigureAwait(false); - if (result.Error != null) + // Idempotent: if a catalog with this name already exists, remove it + // first so re-registration (e.g. token refresh) is a no-op for the + // happy path. Errors from remove are swallowed at debug \u2014 add_catalog + // will surface the real problem. + try + { + var rm = await _coreInterop.ExecuteCommandAsync( + "remove_catalog", + new CoreInteropRequest { Params = new Dictionary { ["Name"] = name } }, + ct).ConfigureAwait(false); + if (rm.Error != null) + { + _logger.LogDebug("remove_catalog('{Name}') before add returned: {Err}", name, rm.Error); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) { - throw new FoundryLocalException($"Error adding catalog '{name}': {result.Error}", _logger); + _logger.LogDebug(ex, "remove_catalog('{Name}') before add threw (ignored).", name); + } + + var add = await _coreInterop.ExecuteCommandAsync( + "add_catalog", new CoreInteropRequest { Params = p }, ct).ConfigureAwait(false); + if (add.Error != null) + { + throw Utils.FromNativeError("add_catalog", add.Error, ct, _logger, + context: $"Error adding catalog '{name}'"); } - // Force model list refresh to pick up new catalog's models InvalidateCache(); await UpdateModels(ct).ConfigureAwait(false); }, $"Error adding catalog '{name}'.", _logger).ConfigureAwait(false); } + public async Task RemoveCatalogAsync(string name, CancellationToken? ct = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Catalog name must be a non-empty, non-whitespace string.", nameof(name)); + } + + await Utils.CallWithExceptionHandling(async () => + { + var request = new CoreInteropRequest { Params = new Dictionary { ["Name"] = name } }; + var result = await _coreInterop.ExecuteCommandAsync("remove_catalog", request, ct).ConfigureAwait(false); + if (result.Error != null) + { + throw Utils.FromNativeError("remove_catalog", result.Error, ct, _logger, + context: $"Error removing catalog '{name}'"); + } + InvalidateCache(); + await UpdateModels(ct).ConfigureAwait(false); + }, $"Error removing catalog '{name}'.", _logger).ConfigureAwait(false); + } + public async Task> GetCatalogNamesAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(async () => diff --git a/sdk/cs/src/Detail/ModelLoadManager.cs b/sdk/cs/src/Detail/ModelLoadManager.cs index 76b48539a..e323bc96c 100644 --- a/sdk/cs/src/Detail/ModelLoadManager.cs +++ b/sdk/cs/src/Detail/ModelLoadManager.cs @@ -53,7 +53,8 @@ public async Task LoadAsync(string modelId, CancellationToken? ct = null) var result = await _coreInterop.ExecuteCommandAsync("load_model", request, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error loading model {modelId}: {result.Error}"); + throw Utils.FromNativeError("load_model", result.Error, ct, _logger, + context: $"Error loading model {modelId}"); } // currently just a 'model loaded successfully' message @@ -72,7 +73,8 @@ public async Task UnloadAsync(string modelId, CancellationToken? ct = null) var result = await _coreInterop.ExecuteCommandAsync("unload_model", request, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error unloading model {modelId}: {result.Error}"); + throw Utils.FromNativeError("unload_model", result.Error, ct, _logger, + context: $"Error unloading model {modelId}"); } _logger.LogInformation("Model {ModelId} unloaded successfully: {Message}", modelId, result.Data); diff --git a/sdk/cs/src/Detail/ModelVariant.cs b/sdk/cs/src/Detail/ModelVariant.cs index 250c601a2..4c800555e 100644 --- a/sdk/cs/src/Detail/ModelVariant.cs +++ b/sdk/cs/src/Detail/ModelVariant.cs @@ -127,22 +127,22 @@ private async Task GetPathImplAsync(CancellationToken? ct = null) var result = await _coreInterop.ExecuteCommandAsync("get_model_path", request, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException( - $"Error getting path for model {Id}: {result.Error}. Has it been downloaded?"); + throw Utils.FromNativeError("get_model_path", result.Error, ct, _logger, + context: $"Error getting path for model {Id} (has it been downloaded?)"); } var path = result.Data!; return path; } + // Re-fire the user's progress callback every 5s when the native layer + // goes quiet, so spinners/UIs don't look hung during slow blob reads. + private static readonly TimeSpan DownloadHeartbeatInterval = TimeSpan.FromSeconds(5); + private async Task DownloadImplAsync(Action? downloadProgress = null, CancellationToken? ct = null) { - var request = new CoreInteropRequest - { - Params = new() { { "Model", Id } } - }; - + var request = new CoreInteropRequest { Params = new() { { "Model", Id } } }; ICoreInterop.Response? response; if (downloadProgress == null) @@ -151,21 +151,56 @@ private async Task DownloadImplAsync(Action? downloadProgress = null, } else { - var callback = new ICoreInterop.CallbackFn(progressString => + float lastProgress = 0f; + var lastUtc = DateTime.UtcNow; + var sync = new object(); + + var callback = new ICoreInterop.CallbackFn(s => { - if (float.TryParse(progressString, out var progress)) + if (float.TryParse(s, out var p)) { - downloadProgress(progress); + lock (sync) { lastProgress = p; lastUtc = DateTime.UtcNow; } + downloadProgress(p); } }); - response = await _coreInterop.ExecuteCommandWithCallbackAsync("download_model", request, - callback, ct).ConfigureAwait(false); + using var hbCts = new CancellationTokenSource(); + var hb = Task.Run(async () => + { + try + { + while (!hbCts.IsCancellationRequested) + { + await Task.Delay(DownloadHeartbeatInterval, hbCts.Token).ConfigureAwait(false); + float p; TimeSpan idle; + lock (sync) { p = lastProgress; idle = DateTime.UtcNow - lastUtc; } + if (idle >= DownloadHeartbeatInterval) + { + _logger.LogDebug("Download heartbeat {ModelId}: {Pct:F1}% (idle {Idle:F0}s)", + Id, p, idle.TotalSeconds); + try { downloadProgress(p); } catch { /* don't let a buggy callback abort the download */ } + } + } + } + catch (OperationCanceledException) { } + }, hbCts.Token); + + try + { + response = await _coreInterop.ExecuteCommandWithCallbackAsync("download_model", request, + callback, ct).ConfigureAwait(false); + } + finally + { + hbCts.Cancel(); + try { await hb.ConfigureAwait(false); } catch { } + } } if (response.Error != null) { - throw new FoundryLocalException($"Error downloading model {Id}: {response.Error}"); + throw Utils.FromNativeError("download_model", response.Error, ct, _logger, + context: $"Error downloading model {Id}"); } } @@ -176,7 +211,8 @@ private async Task RemoveFromCacheImplAsync(CancellationToken? ct = null) var result = await _coreInterop.ExecuteCommandAsync("remove_cached_model", request, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error removing model {Id} from cache: {result.Error}"); + throw Utils.FromNativeError("remove_cached_model", result.Error, ct, _logger, + context: $"Error removing model {Id} from cache"); } } diff --git a/sdk/cs/src/FoundryModelInfo.cs b/sdk/cs/src/FoundryModelInfo.cs index 2d1327cc9..17c2f5c8f 100644 --- a/sdk/cs/src/FoundryModelInfo.cs +++ b/sdk/cs/src/FoundryModelInfo.cs @@ -131,4 +131,31 @@ public record ModelInfo [JsonPropertyName("capabilities")] public string? Capabilities { get; init; } + + /// + /// Registry name parsed from when it matches + /// azureml://registries/<name>/... (or any URI containing + /// registries/<name>/). Null otherwise. Used by + /// to filter private vs public models. + /// + [JsonIgnore] + public string? RegistryName + { + get + { + const string marker = "registries/"; + if (string.IsNullOrEmpty(Uri)) return null; + var idx = Uri.IndexOf(marker, System.StringComparison.OrdinalIgnoreCase); + if (idx < 0) return null; + var start = idx + marker.Length; + var end = Uri.IndexOf('/', start); + if (end < 0) end = Uri.Length; + return end > start ? Uri.Substring(start, end - start) : null; + } + } + + /// Case-insensitive match against . + public bool IsFromCatalogRegistry(string? registryName) => + !string.IsNullOrEmpty(registryName) && + string.Equals(RegistryName, registryName, System.StringComparison.OrdinalIgnoreCase); } diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 4ad5b13ec..73668a5b4 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -21,6 +21,23 @@ public interface ICatalog /// List of IModel instances. Task> ListModelsAsync(CancellationToken? ct = null); + /// + /// List the available models filtered to those whose + /// matches (case-insensitive). + /// + /// + /// Registry name to filter by (e.g. mds-acme-registry for an MDS-hosted + /// private catalog, or the name of the Azure ML public registry). Required. + /// + /// Optional CancellationToken. + /// List of IModel instances whose URI is rooted at the given registry. + /// + /// This is a client-side filter on the result of ; + /// no extra round-trips to the broker. Useful for samples / UIs that want to + /// group "public catalog" vs "private catalog" models. + /// + Task> ListModelsAsync(string catalogRegistryName, CancellationToken? ct = null); + /// /// Lookup a model by its alias. /// @@ -29,6 +46,23 @@ public interface ICatalog /// The matching IModel, or null if no model with the given alias exists. Task GetModelAsync(string modelAlias, CancellationToken? ct = null); + /// + /// Lookup a model by its alias, preferring variants that originate from + /// the named catalog registry. If no variants match the preferred registry, + /// falls back to the unfiltered result (same as ). + /// + /// Model alias. + /// + /// Catalog registry name to prefer (e.g. mds-acme-registry). When the + /// same alias is published by both the public and a private catalog this + /// disambiguates which one the caller wants. Null or empty disables the + /// preference and behaves like the single-argument overload. + /// + /// Optional CancellationToken. + Task GetModelAsync(string modelAlias, + string? preferCatalogRegistryName, + CancellationToken? ct = null); + /// /// Lookup a model variant by its unique model id. /// NOTE: This will return an IModel with a single variant. Use GetModelAsync to get an IModel with all available @@ -73,6 +107,22 @@ public interface ICatalog Task AddCatalogAsync(string name, Uri uri, Dictionary? options = null, CancellationToken? ct = null); + /// + /// Idempotent variant of : if a catalog with the + /// same name already exists it is removed and re-added with the supplied + /// options. Use this to refresh credentials (e.g. rotate an expired + /// BearerToken) without restarting the SDK. + /// + Task AddOrUpdateCatalogAsync(string name, Uri uri, Dictionary? options = null, + CancellationToken? ct = null); + + /// + /// Remove a previously-added private catalog by name. No-op for the built-in + /// public catalog. After removal the model list is refreshed so cached + /// models from the removed catalog no longer appear in . + /// + Task RemoveCatalogAsync(string name, CancellationToken? ct = null); + /// /// Get the names of all registered catalogs. /// diff --git a/sdk/cs/src/Utils.cs b/sdk/cs/src/Utils.cs index 09338497b..458572f96 100644 --- a/sdk/cs/src/Utils.cs +++ b/sdk/cs/src/Utils.cs @@ -47,4 +47,36 @@ internal static T CallWithExceptionHandling(Func func, string errorMsg, IL throw new FoundryLocalException(errorMsg, ex, logger); } } + + /// + /// Build a from a native broker error string. + /// Adds a hint when the native layer reports "cancelled" but the caller did not + /// actually cancel — almost always a server/auth/network failure mis-reported as + /// cancellation. Stashes raw error + command on . + /// + internal static FoundryLocalException FromNativeError( + string commandName, string nativeError, CancellationToken? ct, ILogger logger, string? context = null) + { + var prefix = context ?? $"Error executing '{commandName}'"; + var userCancelled = ct.HasValue && ct.Value.IsCancellationRequested; +#if NETSTANDARD2_0 + var looksCancel = nativeError.IndexOf("cancel", System.StringComparison.OrdinalIgnoreCase) >= 0; +#else + var looksCancel = nativeError.Contains("cancel", System.StringComparison.OrdinalIgnoreCase); +#endif + var message = (looksCancel && !userCancelled) + ? $"{prefix}: {nativeError}. Caller did not cancel — likely a server-side failure " + + "(authentication, expired credentials, or network error)." + : $"{prefix}: {nativeError}"; + + logger.LogError("Native command '{Command}' failed: {NativeError} (caller cancelled: {UserCancelled})", + commandName, nativeError, userCancelled); + + var ex = new FoundryLocalException(message); + ex.Data["Command"] = commandName; + ex.Data["NativeError"] = nativeError; + ex.Data["UserCancelled"] = userCancelled; + return ex; + } } + From 50f9e8e4733a2c9dc4b9cb539deee02c4cbb55dc Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 11 May 2026 18:49:50 -0700 Subject: [PATCH 15/19] fix(samples/cs/private-catalog) --- samples/cs/private-catalog/Program.cs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs index 214c4307c..b64dafb1f 100644 --- a/samples/cs/private-catalog/Program.cs +++ b/samples/cs/private-catalog/Program.cs @@ -102,10 +102,9 @@ await FoundryLocalManager.CreateAsync( bool privateRegistered = false; try { - // AddOrUpdateCatalogAsync is idempotent: safe to call when neutron has - // persisted the catalog from a previous run, and lets callers refresh - // credentials (rotate BearerToken) without restarting the SDK. - await catalog.AddOrUpdateCatalogAsync("private", new Uri(mdsHost), + // TODO(after next dev nuget): switch to AddOrUpdateCatalogAsync so this is + // idempotent and survives neutron persisting catalogs across runs. + await catalog.AddCatalogAsync("private", new Uri(mdsHost), options: new Dictionary { ["BearerToken"] = jwt, @@ -121,13 +120,12 @@ await FoundryLocalManager.CreateAsync( } // --- List models (grouped by origin) --- -// Use ModelInfo.IsFromCatalogRegistry (SDK helper) instead of string-matching -// the Uri ourselves — robust to URI shape changes and to neutron persisting -// registered catalogs across runs. +// TODO(after next dev nuget): replace with ModelInfo.IsFromCatalogRegistry. var allModels = await catalog.ListModelsAsync(); var allVariants = allModels.SelectMany(m => m.Variants).ToList(); -bool IsPrivate(IModel v) => v.Info.IsFromCatalogRegistry(registryName); +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(); @@ -166,7 +164,7 @@ await FoundryLocalManager.CreateAsync( input = allVariants[n - 1].Id; } -model = await ResolveModel(catalog, allVariants, input!, privateRegistered ? registryName : null); +model = await ResolveModel(catalog, allVariants, input!); if (model == null) { Console.WriteLine($"\nModel '{input}' not found."); @@ -203,16 +201,16 @@ await model.DownloadAsync(p => // --------------------------------------------------------------------------- static async Task ResolveModel( - ICatalog catalog, List allVariants, string input, string? preferRegistry) + ICatalog catalog, List allVariants, string input) { // Exact variant id var model = await catalog.GetModelVariantAsync(input); if (model != null) return model; - // Alias — if a private catalog is registered prefer its variants so the - // same alias resolves to the private build over the public one. Falls back - // to the unfiltered model when no private variant matches. - var resolved = await catalog.GetModelAsync(input, preferRegistry); + // Alias (prefer generic-cpu variant). + // TODO(after next dev nuget): use catalog.GetModelAsync(input, preferRegistry) + // to disambiguate public+private alias collisions in favour of the private build. + var resolved = await catalog.GetModelAsync(input); if (resolved != null) { var pick = resolved.Variants.FirstOrDefault(v => From 0ff40813c5218ba782dc682dfa048c0e92e433a5 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 11 May 2026 19:03:23 -0700 Subject: [PATCH 16/19] samples/cs/private-catalog: simplify Program.cs to stable SDK surface for CI --- samples/cs/private-catalog/Program.cs | 242 +++----------------------- 1 file changed, 23 insertions(+), 219 deletions(-) diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs index b64dafb1f..21cd19cdb 100644 --- a/samples/cs/private-catalog/Program.cs +++ b/samples/cs/private-catalog/Program.cs @@ -1,178 +1,44 @@ 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. +// Private Catalog sample (placeholder). // -// 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) +// The full private-catalog flow (JWT signing, AddOrUpdateCatalogAsync, token +// refresh, IsFromCatalogRegistry filtering, GetModelAsync(alias, preferRegistry)) +// depends on SDK APIs that ship in Microsoft.AI.Foundry.Local 1.1.0-dev or later. +// Until that package is published to the public feed, this Program.cs is kept +// minimal so the sample compiles against the currently released SDK. +// +// See README.md for the full private-catalog workflow and JWT signing helper. // --------------------------------------------------------------------------- -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) --- -// Use invariant casing for identifier-like values so behaviour doesn't change on -// machines with non-en-US current culture (e.g. Turkish 'I' folding). -var customerLower = mdsCustomer.ToLowerInvariant(); -var safeName = customerLower.Replace(" ", "").Replace("-", ""); -var registryName = $"mds-{customerLower}-registry"; -var issuer = $"https://mds{safeName}jwks.blob.core.windows.net/jwks"; -var kid = $"mds-{customerLower}-key-1"; -var keyPath = Path.Combine(mdsKeyDir, $"{customerLower}-key.pem"); - -if (!File.Exists(keyPath)) +var config = new Configuration { - Console.WriteLine($"Error: Private key not found at {keyPath}"); - Console.WriteLine("Run mds/scripts/onboard.py --customer --test-keys first."); - return; -} + AppName = "private_catalog_sample", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information, +}; -var jwt = SignJwt(keyPath, kid, issuer, registryName); -Console.WriteLine($"Signed JWT for '{mdsCustomer}' (registry={registryName})"); - -// --- Init Foundry Local --- -// NOTE: the `logger` argument is optional. Pass `null` (or omit it) to silence -// SDK logging — the SDK defaults to NullLogger.Instance. The sample wires up a -// real ILogger via Utils.GetAppLogger() so download / catalog progress shows -// up in the console. -await FoundryLocalManager.CreateAsync( - new Configuration { AppName = "private_catalog_sample", LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information }, - Utils.GetAppLogger()); +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); var mgr = FoundryLocalManager.Instance; -// --- 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 -{ - // TODO(after next dev nuget): switch to AddOrUpdateCatalogAsync so this is - // idempotent and survives neutron persisting catalogs across runs. - 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) when (ex is not OperationCanceledException) -{ - Console.WriteLine($"Warning: could not register private catalog ({ex.Message})."); - Console.WriteLine("Continuing with the public catalog only."); -} - -// --- List models (grouped by origin) --- -// TODO(after next dev nuget): replace with ModelInfo.IsFromCatalogRegistry. -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("Available models:"); +var models = await catalog.ListModelsAsync(); +foreach (var m in models) { - 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; + foreach (var v in m.Variants) + { + Console.WriteLine($" - {v.Alias} ({v.Id})"); + } } -model = await ResolveModel(catalog, allVariants, input!); -if (model == null) -{ - Console.WriteLine($"\nModel '{input}' not found."); - return; -} -Console.WriteLine($"\nSelected: {model.Id}"); +var model = await catalog.GetModelAsync("qwen2.5-0.5b") + ?? throw new Exception("Model not found"); -// --- Download / load / chat --- await model.DownloadAsync(p => { Console.Write($"\rDownloading: {p:F1}%"); @@ -184,7 +50,7 @@ await model.DownloadAsync(p => Console.WriteLine(" done."); var chat = await model.GetChatClientAsync(); -var messages = new List { new() { Role = "user", Content = cliPrompt } }; +var messages = new List { new() { Role = "user", Content = "Why is the sky blue?" } }; Console.WriteLine("Chat completion:"); await foreach (var chunk in chat.CompleteChatStreamingAsync(messages, ct)) @@ -195,65 +61,3 @@ await model.DownloadAsync(p => 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). - // TODO(after next dev nuget): use catalog.GetModelAsync(input, preferRegistry) - // to disambiguate public+private alias collisions in favour of the private build. - 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('/', '_'); From cd0af2fc0bfe2c6519843d6733a5fa1122ac47ad Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Tue, 12 May 2026 16:02:52 -0700 Subject: [PATCH 17/19] typed private-catalog options, exp-ms JWT rejection, CatalogAuthException --- sdk/cs/src/Catalog.cs | 72 ++++++++++++++++++++++++++++- sdk/cs/src/FoundryLocalException.cs | 10 ++++ sdk/cs/src/ICatalog.cs | 41 +++++++++++----- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 3ad3007c7..b07ceae4f 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -283,6 +283,59 @@ internal void InvalidateCache() _lastFetch = DateTime.MinValue; } + // Year 3000 ≈ 32503680000 Unix seconds. A larger 'exp'/'iat' almost + // certainly means the caller passed ToUnixTimeMilliseconds() by mistake; + // MDS would later reject it with an opaque 401. + private static void RejectMillisecondJwt(string bearer) + { + var t = bearer.Trim(); + if (t.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { t = t.Substring(7).Trim(); } + var parts = t.Split('.'); + if (parts.Length < 2) { return; } + + string b64 = parts[1].Replace('-', '+').Replace('_', '/'); + b64 += new string('=', (4 - b64.Length % 4) % 4); + byte[] payload; + try { payload = Convert.FromBase64String(b64); } catch (FormatException) { return; } + + try + { + using var doc = JsonDocument.Parse(payload); + foreach (var claim in new[] { "exp", "iat" }) + { + if (doc.RootElement.TryGetProperty(claim, out var v) && + v.ValueKind == JsonValueKind.Number && + v.TryGetInt64(out var n) && n > 32503680000L) + { + throw new ArgumentException( + $"JWT '{claim}' claim ({n}) looks like milliseconds since epoch. " + + "Use DateTimeOffset.UtcNow.ToUnixTimeSeconds(), not ToUnixTimeMilliseconds().", + "options"); + } + } + } + catch (JsonException) { } + } + + private FoundryLocalException ClassifyCatalogError(string cmd, string err, CancellationToken? ct, string context) + { + var p = err.ToLowerInvariant(); + string? reason = + p.Contains("expired") ? "ExpiredToken" : + p.Contains("audience") || p.Contains("\"aud\"") ? "InvalidAudience" : + p.Contains("registry_name") ? "MissingRegistryName" : + p.Contains("signature") ? "InvalidSignature" : + p.Contains("401") || p.Contains("unauthorized") ? "Unauthorized" : + p.Contains("403") || p.Contains("forbidden") ? "Forbidden" : + null; + if (reason != null) + { + _logger.LogError("Catalog auth failure ({Reason}): {Err}", reason, err); + return new CatalogAuthException($"{context}: {err} (reason: {reason})", reason); + } + return Utils.FromNativeError(cmd, err, ct, _logger, context: context); + } + public void Dispose() { _lock.Dispose(); @@ -293,6 +346,16 @@ public Task AddCatalogAsync(string name, Uri uri, CancellationToken? ct = null) => AddOrUpdateCatalogAsync(name, uri, options, ct); + public Task AddCatalogAsync(string name, Uri uri, PrivateCatalogOptions options, + CancellationToken? ct = null) + { + if (options is null) { throw new ArgumentNullException(nameof(options)); } + var d = new Dictionary(); + if (!string.IsNullOrEmpty(options.BearerToken)) { d["BearerToken"] = options.BearerToken!; } + if (!string.IsNullOrEmpty(options.Audience)) { d["Audience"] = options.Audience!; } + return AddOrUpdateCatalogAsync(name, uri, d, ct); + } + public async Task AddOrUpdateCatalogAsync(string name, Uri uri, Dictionary? options = null, CancellationToken? ct = null) @@ -328,6 +391,12 @@ public async Task AddOrUpdateCatalogAsync(string name, Uri uri, } } + // Fail fast on the common 'exp/iat in milliseconds' JWT mistake. + if (options != null && options.TryGetValue("BearerToken", out var bearer) && !string.IsNullOrEmpty(bearer)) + { + RejectMillisecondJwt(bearer!); + } + await Utils.CallWithExceptionHandling(async () => { // Caller-supplied options first; Name/Uri/Type overlaid so they can't @@ -367,8 +436,7 @@ await Utils.CallWithExceptionHandling(async () => "add_catalog", new CoreInteropRequest { Params = p }, ct).ConfigureAwait(false); if (add.Error != null) { - throw Utils.FromNativeError("add_catalog", add.Error, ct, _logger, - context: $"Error adding catalog '{name}'"); + throw ClassifyCatalogError("add_catalog", add.Error, ct, $"Error adding catalog '{name}'"); } InvalidateCache(); diff --git a/sdk/cs/src/FoundryLocalException.cs b/sdk/cs/src/FoundryLocalException.cs index dae5ef042..8432976f6 100644 --- a/sdk/cs/src/FoundryLocalException.cs +++ b/sdk/cs/src/FoundryLocalException.cs @@ -30,3 +30,13 @@ internal FoundryLocalException(string message, Exception innerException, ILogger logger.LogError(innerException, message); } } + +/// +/// Thrown when a private catalog operation fails authentication (bad/expired +/// token, wrong aud, missing registry_name, etc.). +/// +public class CatalogAuthException : FoundryLocalException +{ + public string Reason { get; } + public CatalogAuthException(string message, string reason) : base(message) { Reason = reason; } +} diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 73668a5b4..77d421dc9 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -7,6 +7,17 @@ namespace Microsoft.AI.Foundry.Local; using System.Collections.Generic; +/// +/// Strongly-typed options for . +/// +public sealed class PrivateCatalogOptions +{ + /// JWT bearer token. exp/iat MUST be Unix seconds, not milliseconds. + public string? BearerToken { get; set; } + /// JWT aud claim MDS expects (e.g. "model-distribution-service"). + public string? Audience { get; set; } +} + public interface ICatalog { /// @@ -97,22 +108,28 @@ public interface ICatalog Task GetLatestVersionAsync(IModel model, CancellationToken? ct = null); /// - /// 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. + /// Add a private model catalog. Idempotent: calling with the same + /// replaces the existing registration (use this to + /// rotate an expired BearerToken). The model list is refreshed + /// before returning. /// - /// 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). Pass "Type" to override the default catalog type ("AzurePrivate"). - /// Optional CancellationToken. + /// + /// Recognised keys: BearerToken, Audience, TokenEndpoint, + /// ClientId, ClientSecret, Type (default + /// "AzurePrivate"). If BearerToken is a JWT, its exp/iat + /// MUST be Unix seconds (not milliseconds); the SDK rejects ms-shaped values. + /// Prefer the overload for IntelliSense. + /// + /// Bad name/uri, or JWT exp/iat in milliseconds. + /// MDS rejected the bearer token. Task AddCatalogAsync(string name, Uri uri, Dictionary? options = null, CancellationToken? ct = null); - /// - /// Idempotent variant of : if a catalog with the - /// same name already exists it is removed and re-added with the supplied - /// options. Use this to refresh credentials (e.g. rotate an expired - /// BearerToken) without restarting the SDK. - /// + /// Strongly-typed overload of . + Task AddCatalogAsync(string name, Uri uri, PrivateCatalogOptions options, + CancellationToken? ct = null); + + /// Alias for ; same idempotent behavior. Task AddOrUpdateCatalogAsync(string name, Uri uri, Dictionary? options = null, CancellationToken? ct = null); From 5d18042184088cc0a93b21cf1d0ef993975162af Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Wed, 13 May 2026 22:13:34 -0700 Subject: [PATCH 18/19] drop appsettings.json from private-catalog sample --- samples/cs/nuget.config | 1 + .../cs/private-catalog/PrivateCatalog.csproj | 7 - samples/cs/private-catalog/Program.cs | 263 ++++++++++++++++-- samples/cs/private-catalog/appsettings.json | 5 - sdk/cs/src/Microsoft.AI.Foundry.Local.csproj | 1 + 5 files changed, 242 insertions(+), 35 deletions(-) delete mode 100644 samples/cs/private-catalog/appsettings.json diff --git a/samples/cs/nuget.config b/samples/cs/nuget.config index 765346e53..294478a7c 100644 --- a/samples/cs/nuget.config +++ b/samples/cs/nuget.config @@ -3,5 +3,6 @@ + diff --git a/samples/cs/private-catalog/PrivateCatalog.csproj b/samples/cs/private-catalog/PrivateCatalog.csproj index 48226f128..27be80d6f 100644 --- a/samples/cs/private-catalog/PrivateCatalog.csproj +++ b/samples/cs/private-catalog/PrivateCatalog.csproj @@ -59,11 +59,4 @@ - - - - PreserveNewest - - - diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs index 21cd19cdb..caf5113d8 100644 --- a/samples/cs/private-catalog/Program.cs +++ b/samples/cs/private-catalog/Program.cs @@ -1,44 +1,188 @@ -using Microsoft.AI.Foundry.Local; +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 (placeholder). +// Private Catalog sample — register a customer MDS catalog with a self-signed +// JWT, list public + private models, and run a streaming chat completion. // -// The full private-catalog flow (JWT signing, AddOrUpdateCatalogAsync, token -// refresh, IsFromCatalogRegistry filtering, GetModelAsync(alias, preferRegistry)) -// depends on SDK APIs that ship in Microsoft.AI.Foundry.Local 1.1.0-dev or later. -// Until that package is published to the public feed, this Program.cs is kept -// minimal so the sample compiles against the currently released SDK. +// Required: +// --customer Customer name (or env MDS_CUSTOMER) +// --key-dir Directory with -key.pem (or env MDS_KEY_DIR) // -// See README.md for the full private-catalog workflow and JWT signing helper. +// Optional: +// --host MDS host (env MDS_HOST, +// default https://mds-web-app.azurewebsites.net) +// --model Alias or variant id (otherwise interactive picker) +// --prompt Prompt (default "Why is the sky blue?") +// --list List models and exit +// --no-private Skip private-catalog registration (public only) +// --show-uri Print variant URIs alongside the listing // --------------------------------------------------------------------------- -CancellationToken ct = default; +string mdsHost = Environment.GetEnvironmentVariable("MDS_HOST") + ?? "https://mds-web-app.azurewebsites.net"; +string? mdsCustomer = Environment.GetEnvironmentVariable("MDS_CUSTOMER"); +string? mdsKeyDir = Environment.GetEnvironmentVariable("MDS_KEY_DIR"); +string? cliModel = null; +string cliPrompt = "Why is the sky blue?"; +bool listOnly = false; +bool noPrivate = false; +bool showUri = false; -var config = new Configuration +for (int i = 0; i < args.Length; i++) { - AppName = "private_catalog_sample", - LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information, -}; + string Next() + { + if (i + 1 >= args.Length) + { + Console.WriteLine($"Error: {args[i]} requires a value."); + Environment.Exit(1); + } + return args[++i]; + } + + switch (args[i]) + { + case "-c": case "--customer": mdsCustomer = Next(); break; + case "--key-dir": mdsKeyDir = Next(); break; + case "--host": mdsHost = Next(); break; + case "-m": case "--model": cliModel = Next(); break; + case "-p": case "--prompt": cliPrompt = Next(); break; + case "-l": case "--list": listOnly = true; break; + case "--no-private": noPrivate = true; break; + case "--show-uri": showUri = true; break; + case "-h": case "--help": PrintUsage(); return; + default: + Console.WriteLine($"Unknown argument: {args[i]}"); + PrintUsage(); + return; + } +} + +if (string.IsNullOrWhiteSpace(mdsCustomer)) +{ + Console.WriteLine("Error: --customer (or MDS_CUSTOMER) is required."); + PrintUsage(); + return; +} +if (string.IsNullOrWhiteSpace(mdsKeyDir)) +{ + Console.WriteLine("Error: --key-dir (or MDS_KEY_DIR) is required."); + PrintUsage(); + return; +} + +// --- Derive customer resources (same convention as mds/scripts/onboard.py) --- +var customerLower = mdsCustomer.ToLowerInvariant(); +var safeName = customerLower.Replace(" ", "").Replace("-", ""); +var registryName = $"mds-{customerLower}-registry"; +var issuer = $"https://mds{safeName}jwks.blob.core.windows.net/jwks"; +var kid = $"mds-{customerLower}-key-1"; +var keyPath = Path.Combine(mdsKeyDir, $"{customerLower}-key.pem"); + +if (!File.Exists(keyPath)) +{ + Console.WriteLine($"Error: Private key not found at {keyPath}"); + Console.WriteLine("Run: python mds/scripts/onboard.py --customer --test-keys"); + return; +} -await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +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; +// --- Register private catalog (falls back to public-only on failure) --- var catalog = await mgr.GetCatalogAsync(); +bool privateRegistered = false; -Console.WriteLine("Available models:"); -var models = await catalog.ListModelsAsync(); -foreach (var m in models) +if (noPrivate) +{ + Console.WriteLine("\n[--no-private] Skipping AddCatalogAsync."); +} +else { - foreach (var v in m.Variants) + Console.WriteLine($"\nRegistering private catalog at {mdsHost}..."); + try { - Console.WriteLine($" - {v.Alias} ({v.Id})"); + 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) when (ex is not OperationCanceledException) + { + Console.WriteLine($"Warning: could not register private catalog ({ex.Message})."); + Console.WriteLine("Continuing with public catalog only."); } } -var model = await catalog.GetModelAsync("qwen2.5-0.5b") - ?? throw new Exception("Model not found"); +// --- List models grouped by origin --- +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(); +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 (showUri) Console.WriteLine($" uri: {v.Info.Uri}"); +} +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 (showUri) Console.WriteLine($" uri: {v.Info.Uri}"); + } +} + +if (listOnly) return; + +// --- Resolve a model (CLI or interactive) --- +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; +} + +var 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}%"); @@ -50,10 +194,10 @@ await model.DownloadAsync(p => Console.WriteLine(" done."); var chat = await model.GetChatClientAsync(); -var messages = new List { new() { Role = "user", Content = "Why is the sky blue?" } }; +var messages = new List { new() { Role = "user", Content = cliPrompt } }; Console.WriteLine("Chat completion:"); -await foreach (var chunk in chat.CompleteChatStreamingAsync(messages, ct)) +await foreach (var chunk in chat.CompleteChatStreamingAsync(messages, CancellationToken.None)) { Console.Write(chunk.Choices[0].Message.Content); Console.Out.Flush(); @@ -61,3 +205,76 @@ await model.DownloadAsync(p => Console.WriteLine(); await model.UnloadAsync(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void PrintUsage() +{ + Console.WriteLine("Usage: PrivateCatalog --customer --key-dir [options]"); + Console.WriteLine(" -c, --customer Customer name (env MDS_CUSTOMER)"); + Console.WriteLine(" --key-dir Dir with -key.pem (env MDS_KEY_DIR)"); + Console.WriteLine(" --host MDS host (env MDS_HOST)"); + Console.WriteLine(" -m, --model Model alias or variant id"); + Console.WriteLine(" -p, --prompt Prompt (default \"Why is the sky blue?\")"); + Console.WriteLine(" -l, --list List models and exit"); + Console.WriteLine(" --no-private Skip private-catalog registration"); + Console.WriteLine(" --show-uri Print variant URIs in the listing"); +} + +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) + 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 + 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/appsettings.json b/samples/cs/private-catalog/appsettings.json deleted file mode 100644 index 6f4f52773..000000000 --- a/samples/cs/private-catalog/appsettings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "MdsHost": "https://mds-web-app.azurewebsites.net", - "MdsCustomer": "", - "MdsKeyDir": "C:/path/to/MDS/scripts" -} diff --git a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj index 384b44151..7e7242a2e 100644 --- a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj +++ b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj @@ -15,6 +15,7 @@ git net8.0;netstandard2.0 + win-x64;win-arm64;osx-arm64;linux-x64 true enable True From 602934ced8d09fcc7cd7120fbd363584e3ec3954 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Thu, 14 May 2026 15:27:35 -0700 Subject: [PATCH 19/19] Drop Uri parameter from AddCatalogAsync (Core hard-codes MDS endpoint) --- samples/cs/private-catalog/Program.cs | 18 +++++---------- sdk/cs/src/Catalog.cs | 23 +++++-------------- sdk/cs/src/ICatalog.cs | 16 +++++++------ .../CatalogManagementTests.cs | 2 +- 4 files changed, 22 insertions(+), 37 deletions(-) diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs index caf5113d8..b7af32955 100644 --- a/samples/cs/private-catalog/Program.cs +++ b/samples/cs/private-catalog/Program.cs @@ -13,8 +13,6 @@ // --key-dir Directory with -key.pem (or env MDS_KEY_DIR) // // Optional: -// --host MDS host (env MDS_HOST, -// default https://mds-web-app.azurewebsites.net) // --model Alias or variant id (otherwise interactive picker) // --prompt Prompt (default "Why is the sky blue?") // --list List models and exit @@ -22,8 +20,6 @@ // --show-uri Print variant URIs alongside the listing // --------------------------------------------------------------------------- -string mdsHost = Environment.GetEnvironmentVariable("MDS_HOST") - ?? "https://mds-web-app.azurewebsites.net"; string? mdsCustomer = Environment.GetEnvironmentVariable("MDS_CUSTOMER"); string? mdsKeyDir = Environment.GetEnvironmentVariable("MDS_KEY_DIR"); string? cliModel = null; @@ -48,7 +44,6 @@ string Next() { case "-c": case "--customer": mdsCustomer = Next(); break; case "--key-dir": mdsKeyDir = Next(); break; - case "--host": mdsHost = Next(); break; case "-m": case "--model": cliModel = Next(); break; case "-p": case "--prompt": cliPrompt = Next(); break; case "-l": case "--list": listOnly = true; break; @@ -113,15 +108,14 @@ await FoundryLocalManager.CreateAsync( } else { - Console.WriteLine($"\nRegistering private catalog at {mdsHost}..."); + Console.WriteLine("\nRegistering private catalog..."); try { - await catalog.AddCatalogAsync("private", new Uri(mdsHost), - options: new Dictionary - { - ["BearerToken"] = jwt, - ["Audience"] = "model-distribution-service", - }); + await catalog.AddCatalogAsync("private", new PrivateCatalogOptions + { + BearerToken = jwt, + Audience = "model-distribution-service", + }); privateRegistered = true; Console.WriteLine("Private catalog registered."); } diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index b07ceae4f..954317d28 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -341,44 +341,34 @@ public void Dispose() _lock.Dispose(); } - public Task AddCatalogAsync(string name, Uri uri, + public Task AddCatalogAsync(string name, Dictionary? options = null, CancellationToken? ct = null) - => AddOrUpdateCatalogAsync(name, uri, options, ct); + => AddOrUpdateCatalogAsync(name, options, ct); - public Task AddCatalogAsync(string name, Uri uri, PrivateCatalogOptions options, + public Task AddCatalogAsync(string name, PrivateCatalogOptions options, CancellationToken? ct = null) { if (options is null) { throw new ArgumentNullException(nameof(options)); } var d = new Dictionary(); if (!string.IsNullOrEmpty(options.BearerToken)) { d["BearerToken"] = options.BearerToken!; } if (!string.IsNullOrEmpty(options.Audience)) { d["Audience"] = options.Audience!; } - return AddOrUpdateCatalogAsync(name, uri, d, ct); + return AddOrUpdateCatalogAsync(name, d, ct); } - public async Task AddOrUpdateCatalogAsync(string name, Uri uri, + public async Task AddOrUpdateCatalogAsync(string name, Dictionary? options = null, CancellationToken? ct = null) { #if NET7_0_OR_GREATER ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(uri); #else if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Catalog name must be a non-empty, non-whitespace string.", nameof(name)); } - if (uri is null) - { - throw new ArgumentNullException(nameof(uri)); - } #endif - if (uri.Scheme != "https" && uri.Scheme != "http") - { - throw new ArgumentException($"Catalog URI must use http or https scheme, got '{uri.Scheme}'.", nameof(uri)); - } - if (options != null && options.TryGetValue("TokenEndpoint", out var tokenEndpoint) && tokenEndpoint != null) { if (!Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var parsedEndpoint)) @@ -399,13 +389,12 @@ public async Task AddOrUpdateCatalogAsync(string name, Uri uri, await Utils.CallWithExceptionHandling(async () => { - // Caller-supplied options first; Name/Uri/Type overlaid so they can't + // Caller-supplied options first; Name/Type overlaid so they can't // be silently overridden. Default Type to AzurePrivate; honour an // explicit "Type" in options. var p = new Dictionary(options ?? new Dictionary()) { ["Name"] = name, - ["Uri"] = uri.ToString(), }; if (!p.TryGetValue("Type", out var typeValue) || string.IsNullOrEmpty(typeValue)) { diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 77d421dc9..5aadcd8e0 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -8,7 +8,7 @@ namespace Microsoft.AI.Foundry.Local; using System.Collections.Generic; /// -/// Strongly-typed options for . +/// Strongly-typed options for . /// public sealed class PrivateCatalogOptions { @@ -108,7 +108,9 @@ public interface ICatalog Task GetLatestVersionAsync(IModel model, CancellationToken? ct = null); /// - /// Add a private model catalog. Idempotent: calling with the same + /// Add a private model catalog. The endpoint is fixed (the SDK targets the + /// Model Distribution Service); only and credentials + /// are caller-provided. Idempotent: calling with the same /// replaces the existing registration (use this to /// rotate an expired BearerToken). The model list is refreshed /// before returning. @@ -120,17 +122,17 @@ public interface ICatalog /// MUST be Unix seconds (not milliseconds); the SDK rejects ms-shaped values. /// Prefer the overload for IntelliSense. /// - /// Bad name/uri, or JWT exp/iat in milliseconds. + /// Bad name, or JWT exp/iat in milliseconds. /// MDS rejected the bearer token. - Task AddCatalogAsync(string name, Uri uri, Dictionary? options = null, + Task AddCatalogAsync(string name, Dictionary? options = null, CancellationToken? ct = null); - /// Strongly-typed overload of . - Task AddCatalogAsync(string name, Uri uri, PrivateCatalogOptions options, + /// Strongly-typed overload of . + Task AddCatalogAsync(string name, PrivateCatalogOptions options, CancellationToken? ct = null); /// Alias for ; same idempotent behavior. - Task AddOrUpdateCatalogAsync(string name, Uri uri, Dictionary? options = null, + Task AddOrUpdateCatalogAsync(string name, 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 7858f317d..7c6a7ca48 100644 --- a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs @@ -41,7 +41,7 @@ public async Task Test_AddCatalog() new() { CommandName = "add_catalog", ResponseData = "OK" } ]); - await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"), + await catalog.AddCatalogAsync("priv", new Dictionary { ["ClientId"] = "id", ["ClientSecret"] = "secret" }); await Assert.That(catalog).IsNotNull(); }