diff --git a/samples/cs/README.md b/samples/cs/README.md index ad10a3c6..9207fe40 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 00000000..a49711c2 --- /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 00000000..6d66e4fa --- /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 00000000..40f1aeca --- /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 00000000..1e110123 --- /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 00000000..aab3dbb2 --- /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" +} diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index f33dcaff..e0d3400d 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -249,4 +249,74 @@ public void Dispose() { _lock.Dispose(); } + + public async Task AddCatalogAsync(string name, Uri uri, + Dictionary? options = null, + CancellationToken? ct = 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 (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}'."); + } + if (parsedEndpoint.Scheme != "https" && parsedEndpoint.Scheme != "http") + { + throw new ArgumentException($"Token endpoint must use http or https scheme, got '{parsedEndpoint.Scheme}'."); + } + } + + 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. + var p = new Dictionary(options ?? new Dictionary()) + { + ["Name"] = name, + ["Uri"] = uri.ToString(), + }; + if (!p.TryGetValue("Type", out var typeValue) || string.IsNullOrEmpty(typeValue)) + { + p["Type"] = "AzurePrivate"; + } + var request = new CoreInteropRequest { Params = p }; + + 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 + InvalidateCache(); + await UpdateModels(ct).ConfigureAwait(false); + }, $"Error adding catalog '{name}'.", _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/CoreInterop.cs b/sdk/cs/src/Detail/CoreInterop.cs index b88f5597..ff8e3cc3 100644 --- a/sdk/cs/src/Detail/CoreInterop.cs +++ b/sdk/cs/src/Detail/CoreInterop.cs @@ -324,7 +324,6 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, if (response.Error != IntPtr.Zero && response.ErrorLength > 0) { result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength)!; - _logger.LogDebug($"Input:{commandInput ?? "null"}"); _logger.LogDebug($"Command: {commandName} Error: {result.Error}"); } @@ -342,7 +341,7 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, } catch (Exception ex) when (ex is not OperationCanceledException) { - var msg = $"Error executing command '{commandName}' with input {commandInput ?? "null"}"; + var msg = $"Error executing command '{commandName}'"; throw new FoundryLocalException(msg, ex, _logger); } } diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 0fe5e677..d0180d5a 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. // @@ -41,6 +41,7 @@ namespace Microsoft.AI.Foundry.Local.Detail; // which has AOT-incompatible JsonConverters, so we only register the raw deserialization type) --- [JsonSerializable(typeof(LiveAudioTranscriptionRaw))] [JsonSerializable(typeof(CoreErrorResponse))] +[JsonSerializable(typeof(List))] // catalog names [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false)] internal partial class JsonSerializationContext : JsonSerializerContext diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 4dca8e7d..4ad5b13e 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,22 @@ 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. 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. + /// 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); + + /// + /// Get the names of all registered catalogs. + /// + /// Optional CancellationToken. + /// List of catalog name strings. + Task> GetCatalogNamesAsync(CancellationToken? ct = null); } diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs new file mode 100644 index 00000000..7858f317 --- /dev/null +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs @@ -0,0 +1,59 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// 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_models", ResponseData = "[]" }, + .. extra + ]; + + var ci = Utils.CreateCoreInteropWithIntercept(Utils.CoreInterop, intercepts); + return await Catalog.CreateAsync(lm.Object, ci.Object, logger.Object); + } + + [Test] + public async Task Test_AddCatalog() + { + using var catalog = await CreateCatalogWithIntercepts( + [ + new() { CommandName = "add_catalog", ResponseData = "OK" } + ]); + + await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"), + new Dictionary { ["ClientId"] = "id", ["ClientSecret"] = "secret" }); + 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"); + } +}