Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
282f0a9
Private catalog SDK support: AddCatalogAsync, SelectCatalogAsync, Get…
Mar 16, 2026
ba4b050
fixing native errors
Mar 24, 2026
f2725a4
private catalog sdk improvements
kobby-kobbs Apr 6, 2026
b3ed6db
fixed comments
kobby-kobbs Apr 6, 2026
6c4c8da
Merge branch 'main' into emmanuel/privatecatalog
kobby-kobbs Apr 7, 2026
62c21fc
Merge branch 'main' into emmanuel/privatecatalog
kobby-kobbs Apr 9, 2026
1b62e18
Merge branch 'main' into emmanuel/privatecatalog
kobby-kobbs Apr 14, 2026
514a780
Address PR review: use InvalidateCache, move optional args to options…
kobby-kobbs Apr 14, 2026
f3dbe71
Merge branch 'main' into emmanuel/privatecatalog
kobby-kobbs Apr 15, 2026
8456f35
SDK: send Type in add_catalog; remove SelectCatalogAsync
kobby-kobbs Apr 20, 2026
2f6b743
replaced containsKey with TryGetValue
kobby-kobbs Apr 22, 2026
d117001
Merge remote-tracking branch 'origin/main' into emmanuel/privatecatalog
kobby-kobbs Apr 22, 2026
c550364
samples(cs): add private-catalog sample
Apr 23, 2026
921377b
Merge remote-tracking branch 'origin/main' into emmanuel/privatecatalog
Apr 30, 2026
cd63f35
Add ORT-Nightly package source to nuget.config
baijumeswani Apr 30, 2026
f53975a
Remove ORT-Nightly package source from nuget.config
baijumeswani May 1, 2026
87bf36a
Merge origin/emmanuel/privatecatalog: bug bash sample updates
May 1, 2026
8728a61
Merge branch 'emmanuel/privatecatalog' of https://github.com/microsof…
May 1, 2026
0e6b413
Fix stale onboarding script reference in error message
May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions samples/cs/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<CentralPackageFloatingVersionsEnabled>true</CentralPackageFloatingVersionsEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AI.Foundry.Local" Version="*-*" />
<PackageVersion Include="Microsoft.AI.Foundry.Local.WinML" Version="*-*" />
<PackageVersion Include="Microsoft.AI.Foundry.Local" Version="1.1.0-dev.202605010202" />
<PackageVersion Include="Microsoft.AI.Foundry.Local.WinML" Version="1.1.0-dev.202605010202" />
<PackageVersion Include="Betalgo.Ranul.OpenAI" Version="9.2.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.15" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.15" />
Expand Down
1 change: 1 addition & 0 deletions samples/cs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
14 changes: 1 addition & 13 deletions samples/cs/nuget.config
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,5 @@
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<!-- CI builds the SDK to local-packages/ before building samples.
For local dev, run: dotnet pack sdk/cs/src -o local-packages /p:Version=0.9.0-dev -->
<add key="local-sdk" value="../../local-packages" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
<package pattern="Microsoft.AI.Foundry.Local.Core*" />
</packageSource>
<packageSource key="local-sdk">
<package pattern="Microsoft.AI.Foundry.Local*" />
</packageSource>
</packageSourceMapping>
</configuration>
</configuration>
55 changes: 55 additions & 0 deletions samples/cs/private-catalog/PrivateCatalog.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<!-- Windows: target Windows SDK for WinML hardware acceleration -->
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<TargetFramework>net9.0-windows10.0.26100</TargetFramework>
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
<Platforms>ARM64;x64</Platforms>
<WindowsPackageType>None</WindowsPackageType>
<EnableCoreMrtTooling>false</EnableCoreMrtTooling>
</PropertyGroup>

<!-- Non-Windows: standard .NET -->
<PropertyGroup Condition="!$([MSBuild]::IsOSPlatform('Windows'))">
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>

<PropertyGroup Condition="'$(RuntimeIdentifier)'==''">
<RuntimeIdentifier>$(NETCoreSdkRuntimeIdentifier)</RuntimeIdentifier>
</PropertyGroup>

<!-- Windows: WinML for hardware acceleration -->
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<PackageReference Include="Microsoft.AI.Foundry.Local.WinML" />
</ItemGroup>

<!-- Non-Windows: standard SDK -->
<ItemGroup Condition="!$([MSBuild]::IsOSPlatform('Windows'))">
<PackageReference Include="Microsoft.AI.Foundry.Local" />
</ItemGroup>

<!-- Linux GPU support -->
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
<PackageReference Include="Microsoft.ML.OnnxRuntime.Gpu" />
<PackageReference Include="Microsoft.ML.OnnxRuntimeGenAI.Cuda" />
</ItemGroup>

<!-- Shared utilities (spinner, logger) -->
<ItemGroup>
<Compile Include="../Shared/*.cs" />
</ItemGroup>

<!-- Copy appsettings.json next to the binary -->
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
34 changes: 34 additions & 0 deletions samples/cs/private-catalog/PrivateCatalog.sln
Original file line number Diff line number Diff line change
@@ -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
251 changes: 251 additions & 0 deletions samples/cs/private-catalog/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <name> Model alias or variant id");
Console.WriteLine(" -c, --customer <name> Customer name (default: from appsettings)");
Console.WriteLine(" -p, --prompt <text> 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 <name> --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<string, string>
{
["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/<mds-registry>/...` 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<ChatMessage> { 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<IModel?> ResolveModel(
ICatalog catalog, List<IModel> 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<string, object>
{
["iss"] = issuer,
["sub"] = "foundry-local-sample",
["aud"] = "model-distribution-service",
["iat"] = now.ToUnixTimeSeconds(),
["exp"] = now.AddHours(1).ToUnixTimeSeconds(),
["registry_name"] = registryName,
["entitlements"] = new Dictionary<string, object>
{
["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('/', '_');
Loading
Loading