Skip to content
Open
123 changes: 122 additions & 1 deletion sdk/cs/src/Catalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace Microsoft.AI.Foundry.Local;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

Expand Down Expand Up @@ -77,6 +78,19 @@ public async Task<List<ModelVariant>> GetLoadedModelsAsync(CancellationToken? ct
.ConfigureAwait(false);
}

public async Task<Model> DownloadModelAsync(string modelUri, CancellationToken? ct = null)
{
return await Utils.CallWithExceptionHandling(() => DownloadModelImplAsync(modelUri, ct),
$"Error downloading model '{modelUri}'.", _logger)
.ConfigureAwait(false);
}

public Task<Model> RegisterModelAsync(string modelIdentifier, CancellationToken? ct = null)
{
return Task.FromException<Model>(new NotSupportedException(
"RegisterModelAsync is only available on HuggingFace catalogs. Use AddCatalogAsync(\"https://huggingface.co\") to create a HuggingFace catalog."));
}

public async Task<ModelVariant?> GetModelVariantAsync(string modelId, CancellationToken? ct = null)
{
return await Utils.CallWithExceptionHandling(() => GetModelVariantImplAsync(modelId, ct),
Expand Down Expand Up @@ -126,9 +140,29 @@ private async Task<List<ModelVariant>> GetLoadedModelsImplAsync(CancellationToke

private async Task<Model?> GetModelImplAsync(string modelAlias, CancellationToken? ct = null)
{
var hfUrl = NormalizeToHuggingFaceUrl(modelAlias);
if (hfUrl != null)
{
// Force a fresh catalog refresh for HuggingFace lookups
_lastFetch = DateTime.MinValue;
await UpdateModels(ct).ConfigureAwait(false);

using var disposable = await _lock.LockAsync().ConfigureAwait(false);
var matchingVariant = _modelIdToModelVariant.Values.FirstOrDefault(v =>
string.Equals(v.Info.Uri, hfUrl, StringComparison.OrdinalIgnoreCase));

if (matchingVariant != null)
{
_modelAliasToModel.TryGetValue(matchingVariant.Alias, out Model? hfModel);
return hfModel;
}

return null;
}

await UpdateModels(ct).ConfigureAwait(false);

using var disposable = await _lock.LockAsync().ConfigureAwait(false);
using var d = await _lock.LockAsync().ConfigureAwait(false);
_modelAliasToModel.TryGetValue(modelAlias, out Model? model);

return model;
Expand All @@ -143,6 +177,93 @@ private async Task<List<ModelVariant>> GetLoadedModelsImplAsync(CancellationToke
return modelVariant;
}

private async Task<Model> DownloadModelImplAsync(string modelUri, CancellationToken? ct)
{
// Validate that this is a HuggingFace identifier
if (NormalizeToHuggingFaceUrl(modelUri) == null)
{
throw new FoundryLocalException(
$"'{modelUri}' is not a valid HuggingFace URL or org/repo identifier.", _logger);
}

// Send the original URI to Core — it handles full URLs with /tree/revision/
// and raw org/repo/subdir strings. Do NOT send the normalized form, as Core's
// URL parser expects /tree/revision/ when the https:// prefix is present.
var downloadRequest = new CoreInteropRequest
{
Params = new Dictionary<string, string> { { "Model", modelUri } }
};

var result = await _coreInterop.ExecuteCommandAsync("download_model", downloadRequest, ct)
.ConfigureAwait(false);

if (result.Error != null)
{
throw new FoundryLocalException(
$"Error downloading model '{modelUri}': {result.Error}", _logger);
}

// Force a catalog refresh to pick up the newly downloaded model
_lastFetch = DateTime.MinValue;
await UpdateModels(ct).ConfigureAwait(false);

// The backend returns the org/model URI (e.g. "microsoft/Phi-3-mini") as result.Data
using var disposable = await _lock.LockAsync().ConfigureAwait(false);
var expectedUri = $"https://huggingface.co/{result.Data}";
var matchingVariant = _modelIdToModelVariant.Values.FirstOrDefault(v =>
string.Equals(v.Info.Uri, expectedUri, StringComparison.OrdinalIgnoreCase));

if (matchingVariant != null)
{
_modelAliasToModel.TryGetValue(matchingVariant.Alias, out Model? hfModel);
return hfModel!;
}

throw new FoundryLocalException(
$"Model '{modelUri}' was downloaded but could not be found in the catalog.", _logger);
}

/// <summary>
/// Normalizes a model identifier to a canonical HuggingFace URL, or returns null if it's a plain alias.
/// Strips /tree/{revision}/ from full browser URLs so the result matches the stored Info.Uri format.
/// Handles:
/// - "https://huggingface.co/org/repo/tree/main/sub" -> "https://huggingface.co/org/repo/sub"
/// - "https://huggingface.co/org/repo" -> returned as-is
/// - "org/repo[/sub]" -> "https://huggingface.co/org/repo[/sub]"
/// - "phi-3-mini" (plain alias) -> null
/// </summary>
private static string? NormalizeToHuggingFaceUrl(string input)
{
const string hfPrefix = "https://huggingface.co/";

if (input.StartsWith(hfPrefix, StringComparison.OrdinalIgnoreCase))
{
// Strip /tree/{revision}/ to match the canonical form stored by Core
var path = input[hfPrefix.Length..];
var parts = path.Split('/');
if (parts.Length >= 4 &&
parts[2].Equals("tree", StringComparison.OrdinalIgnoreCase))
{
// parts[0]=org, parts[1]=repo, parts[2]="tree", parts[3]=revision, parts[4..]=subpath
var org = parts[0];
var repo = parts[1];
var subPath = parts.Length > 4 ? string.Join("/", parts.Skip(4)) : null;
return subPath != null
? $"{hfPrefix}{org}/{repo}/{subPath}"
: $"{hfPrefix}{org}/{repo}";
}

return input;
}

if (input.Contains('/') && !input.StartsWith("azureml://", StringComparison.OrdinalIgnoreCase))
{
return hfPrefix + input;
}

return null;
}

private async Task UpdateModels(CancellationToken? ct)
{
// TODO: make this configurable
Expand Down
31 changes: 31 additions & 0 deletions sdk/cs/src/FoundryLocalManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ public async Task<ICatalog> GetCatalogAsync(CancellationToken? ct = null)
"Error getting Catalog.", _logger).ConfigureAwait(false);
}

/// <summary>
/// Create a separate catalog for a HuggingFace model registry.
/// </summary>
/// <param name="catalogUrl">URL of the catalog (must contain "huggingface.co").</param>
/// <param name="token">Optional authentication token for accessing private HuggingFace repositories.</param>
/// <param name="ct">Optional CancellationToken.</param>
/// <returns>The HuggingFace catalog instance.</returns>
public async Task<ICatalog> AddCatalogAsync(string catalogUrl, string? token = null,
CancellationToken? ct = null)
{
return await Utils.CallWithExceptionHandling(() => AddCatalogImplAsync(catalogUrl, token, ct),
$"Error adding catalog '{catalogUrl}'.", _logger)
.ConfigureAwait(false);
}

/// <summary>
/// Start the optional web service. This will provide an OpenAI-compatible REST endpoint that supports
/// /v1/chat_completions
Expand Down Expand Up @@ -212,6 +227,22 @@ private async Task<ICatalog> GetCatalogImplAsync(CancellationToken? ct = null)
return _catalog;
}

private async Task<ICatalog> AddCatalogImplAsync(string catalogUrl, string? token,
CancellationToken? ct = null)
{
if (!catalogUrl.Contains("huggingface.co", StringComparison.OrdinalIgnoreCase))
{
throw new FoundryLocalException(
$"Unsupported catalog URL '{catalogUrl}'. Only HuggingFace catalogs (huggingface.co) are supported.",
_logger);
}

#pragma warning disable IDISP005 // Return type is not disposable
return await HuggingFaceCatalog.CreateAsync(_modelManager!, _coreInterop!, _logger, token, ct)
.ConfigureAwait(false);
#pragma warning restore IDISP005
}

private async Task StartWebServiceImplAsync(CancellationToken? ct = null)
{
if (_config?.Web?.Urls == null)
Expand Down
3 changes: 3 additions & 0 deletions sdk/cs/src/FoundryModelInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public record ModelInfo
[JsonPropertyName("version")]
public int Version { get; init; }

[JsonPropertyName("hash")]
public string? Hash { get; init; }

[JsonPropertyName("alias")]
public required string Alias { get; init; }

Expand Down
Loading
Loading