Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
72 changes: 72 additions & 0 deletions samples/cs/private-catalog/PrivateCatalog.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<!-- Windows: target Windows SDK (needed when the private-catalog sample is
run on Windows, but we skip WinML here to avoid conflicting with the
project-referenced SDK below). -->
<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>

<!-- Private catalog (AddCatalogAsync) is on the in-repo SDK only. Reference
it as a project so the sample builds against the latest API. -->
<ItemGroup>
<ProjectReference Include="../../../sdk/cs/src/Microsoft.AI.Foundry.Local.csproj" />
</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>

<!-- Override the NuGet-shipped native Core DLL with a locally built one
(required until the `add_catalog` dispatcher lands in a released
Microsoft.AI.Foundry.Local.Core package). Set LocalCoreDll to point
at your build output. No-op if the path doesn't exist. -->
<PropertyGroup>
<LocalCoreDll Condition="'$(LocalCoreDll)' == ''">$(MSBuildThisFileDirectory)..\..\..\..\neutron-server\artifacts\bin\Core\debug_net9.0_win-x64\Microsoft.AI.Foundry.Local.Core.dll</LocalCoreDll>
</PropertyGroup>
<Target Name="OverrideNativeCore" AfterTargets="Build;Publish" Condition="Exists('$(LocalCoreDll)')">
<Message Importance="High" Text="Overriding native Core with: $(LocalCoreDll)" />
<!-- Target every plausible output location so both `dotnet build` and
`dotnet run` pick up the patched DLL. -->
<ItemGroup>
<_OverrideDest Include="$(OutputPath)" />
<_OverrideDest Include="$(OutputPath)$(RuntimeIdentifier)\" Condition="'$(RuntimeIdentifier)' != ''" />
<_OverrideDest Include="$(PublishDir)" Condition="'$(PublishDir)' != ''" />
</ItemGroup>
<Copy SourceFiles="$(LocalCoreDll)" DestinationFolder="%(_OverrideDest.Identity)" OverwriteReadOnlyFiles="true" />
</Target>

</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
254 changes: 254 additions & 0 deletions samples/cs/private-catalog/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <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/create_jwks_storage.py --customer <name> 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<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