diff --git a/samples/cs/Directory.Packages.props b/samples/cs/Directory.Packages.props index 77b68c4c..1132933a 100644 --- a/samples/cs/Directory.Packages.props +++ b/samples/cs/Directory.Packages.props @@ -4,8 +4,8 @@ true - - + + 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/nuget.config b/samples/cs/nuget.config index 63954b2f..765346e5 100644 --- a/samples/cs/nuget.config +++ b/samples/cs/nuget.config @@ -3,17 +3,5 @@ - - - - - - - - - - - - \ No newline at end of file + diff --git a/samples/cs/private-catalog/PrivateCatalog.csproj b/samples/cs/private-catalog/PrivateCatalog.csproj new file mode 100644 index 00000000..7ad97c28 --- /dev/null +++ b/samples/cs/private-catalog/PrivateCatalog.csproj @@ -0,0 +1,55 @@ + + + + Exe + enable + enable + + + + + net9.0-windows10.0.26100 + false + ARM64;x64 + None + false + + + + + net9.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + 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..47eb7a3b --- /dev/null +++ b/samples/cs/private-catalog/Program.cs @@ -0,0 +1,251 @@ +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/onboard.py --customer --test-keys 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; + +// --- 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..9c1c3133 --- /dev/null +++ b/samples/cs/private-catalog/README.md @@ -0,0 +1,76 @@ +# 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 +- A customer provisioned in MDS (registry + JWKS). Run + `python scripts/onboard.py --customer --subscription --test-keys` + from the [MDS repo](https://github.com/coreai-microsoft/MDS) — this creates + the resources and writes `-key.pem` into `MDS/scripts/`. + The matching JWKS must already be published at + `https://mdsjwks.blob.core.windows.net/jwks/.well-known/jwks.json`. +- At least one model uploaded for that customer + (`python scripts/upload_model.py --customer --name --path ...`). +- 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) with your own values: + +```json +{ + "MdsHost": "https://mds-web-app.azurewebsites.net", + "MdsCustomer": "", + "MdsKeyDir": "C:/path/to/MDS/scripts" +} +``` + +- `MdsHost` — MDS endpoint. Prod is + `https://mds-web-app.azurewebsites.net`; use + `https://mds-web-app-staging.azurewebsites.net` for staging. +- `MdsCustomer` — the same name you passed to `onboard.py`. Used to derive + the registry (`mds--registry`), JWKS URL, and key file name. +- `MdsKeyDir` — folder containing `-key.pem` (typically + `MDS/scripts/`). + +## Run + +From `samples/cs/private-catalog`: + +```powershell +dotnet run +``` + +## 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 (CPU execution provider only — no + `DownloadAndRegisterEpsAsync` call, so the sample doesn't pull GPU/NPU EPs). +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 | +| Build fails with `MSB3027` / file locked on `*.dll` | A previous `PrivateCatalog` process is still running | Close it (or `Stop-Process -Name PrivateCatalog`) and re-run `dotnet run` | diff --git a/samples/cs/private-catalog/appsettings.json b/samples/cs/private-catalog/appsettings.json new file mode 100644 index 00000000..6f4f5277 --- /dev/null +++ b/samples/cs/private-catalog/appsettings.json @@ -0,0 +1,5 @@ +{ + "MdsHost": "https://mds-web-app.azurewebsites.net", + "MdsCustomer": "", + "MdsKeyDir": "C:/path/to/MDS/scripts" +} 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 7239a48e..034bcb53 100644 --- a/sdk/cs/src/Detail/CoreInterop.cs +++ b/sdk/cs/src/Detail/CoreInterop.cs @@ -243,7 +243,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"); + } +}