Skip to content
Merged
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
2 changes: 1 addition & 1 deletion samples/js/hello-foundry-local/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { FoundryLocalManager } from "foundry-local-sdk";
// to your end-user's device.
// TIP: You can find a list of available models by running the
// following command in your terminal: `foundry model list`.
const alias = "phi-3.5-mini";
const alias = "qwen2.5-coder-0.5b-instruct-generic-cpu:3";

// Create a FoundryLocalManager instance. This will start the Foundry
// Local service if it is not already running.
Expand Down
2 changes: 1 addition & 1 deletion samples/python/hello-foundry-local/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# By using an alias, the most suitable model will be downloaded
# to your end-user's device.
alias = "phi-3.5-mini"
alias = "qwen2.5-coder-0.5b-instruct-generic-cpu:3"

# Create a FoundryLocalManager instance. This will start the Foundry
# Local service if it is not already running and load the specified model.
Expand Down
207 changes: 184 additions & 23 deletions sdk/cs/src/FoundryLocalManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,38 @@ public void RefreshCatalog()

public async Task<ModelInfo?> GetModelInfoAsync(string aliasOrModelId, CancellationToken ct = default)
{
var dictionary = await GetCatalogDictAsync(ct);
var catalog = await GetCatalogDictAsync(ct);
ModelInfo? modelInfo = null;

dictionary.TryGetValue(aliasOrModelId, out ModelInfo? model);
return model;
// Direct match (id with version or alias)
if (catalog.TryGetValue(aliasOrModelId, out var directMatch))
{
modelInfo = directMatch;
}
else if (!aliasOrModelId.Contains(':'))
{
// If no direct match and aliasOrModelId does not contain a version suffix
var prefix = aliasOrModelId + ":";
var bestVersion = -1;

foreach (var kvp in catalog)
{
var key = kvp.Key;
ModelInfo model = kvp.Value;

if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
var version = GetVersion(key);
if (version > bestVersion)
{
bestVersion = version;
modelInfo = model;
}
}
}
}

return modelInfo;
}

public async Task<string> GetCacheLocationAsync(CancellationToken ct = default)
Expand Down Expand Up @@ -245,6 +273,53 @@ public async Task<List<ModelInfo>> ListCachedModelsAsync(CancellationToken ct =
return modelInfo;
}

public async Task<bool> IsModelUpgradeableAsync(string aliasOrModelId, CancellationToken ct = default)
{
var modelInfo = await GetLatestModelInfoAsync(aliasOrModelId, ct);
if (modelInfo == null)
{
return false; // Model not found in the catalog
}

var latestVersion = GetVersion(modelInfo.ModelId);
if (latestVersion == -1)
{
return false; // Invalid version format in model ID
}

var cachedModels = await ListCachedModelsAsync(ct);
foreach (var cachedModel in cachedModels)
{
if (cachedModel.ModelId.Equals(modelInfo.ModelId, StringComparison.OrdinalIgnoreCase) &&
GetVersion(cachedModel.ModelId) == latestVersion)
{
// Cached model is already at latest version
return false;
}
}

// Latest version not in cache - upgrade available
return true;

}

public async Task<ModelInfo?> UpgradeModelAsync(string aliasOrModelId, string? token = null, CancellationToken ct = default)
{
// Get the latest model info; throw if not found
var modelInfo = await GetLatestModelInfoAsync(aliasOrModelId, ct)
?? throw new ArgumentException($"Model '{aliasOrModelId}' was not found in the catalog.");

// Attempt to download the model
try
{
return await DownloadModelAsync(modelInfo.ModelId, token, false, ct);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to upgrade model '{aliasOrModelId}'.", ex);
}
}

public async Task<ModelInfo> LoadModelAsync(string aliasOrModelId, TimeSpan? timeout = null, CancellationToken ct = default)
{
var modelInfo = await GetModelInfoAsync(aliasOrModelId, ct) ?? throw new InvalidOperationException($"Model {aliasOrModelId} not found in catalog.");
Expand Down Expand Up @@ -435,39 +510,125 @@ private async Task<List<ModelInfo>> FetchModelInfosAsync(IEnumerable<string> ali

private async Task<Dictionary<string, ModelInfo>> GetCatalogDictAsync(CancellationToken ct = default)
{
if (_catalogDictionary == null)
if (_catalogDictionary != null)
{
return _catalogDictionary;
}

var dict = new Dictionary<string, ModelInfo>(StringComparer.OrdinalIgnoreCase);
var models = await ListCatalogModelsAsync(ct);
foreach (var model in models)
{
dict[model.ModelId] = model;
}

var aliasCandidates = new Dictionary<string, List<ModelInfo>>(StringComparer.OrdinalIgnoreCase);
foreach (var model in models)
{
var dict = new Dictionary<string, ModelInfo>(StringComparer.OrdinalIgnoreCase);
var models = await ListCatalogModelsAsync(ct);
foreach (var model in models)
if (!string.IsNullOrWhiteSpace(model.Alias))
{
dict[model.ModelId] = model;
if (!aliasCandidates.TryGetValue(model.Alias, out var list))
{
list = [];
aliasCandidates[model.Alias] = list;
}
list.Add(model);
}
}

// For each alias, choose the best candidate based on _priorityMap and version
foreach (var kvp in aliasCandidates)
{
var alias = kvp.Key;
List<ModelInfo> candidates = kvp.Value;

if (!string.IsNullOrWhiteSpace(model.Alias))
ModelInfo bestCandidate = candidates.Aggregate((best, current) =>
{
// Get priorities or max int if not found
var bestPriority = _priorityMap.TryGetValue(best.Runtime.ExecutionProvider, out var bp) ? bp : int.MaxValue;
var currentPriority = _priorityMap.TryGetValue(current.Runtime.ExecutionProvider, out var cp) ? cp : int.MaxValue;

if (currentPriority < bestPriority)
{
if (!dict.TryGetValue(model.Alias, out var existing))
{
dict[model.Alias] = model;
}
else
{
var currentPriority = _priorityMap.TryGetValue(model.Runtime.ExecutionProvider, out var cp) ? cp : int.MaxValue;
var existingPriority = _priorityMap.TryGetValue(existing.Runtime.ExecutionProvider, out var ep) ? ep : int.MaxValue;
return current;
}

if (currentPriority < existingPriority)
{
dict[model.Alias] = model;
}
if (currentPriority == bestPriority)
{
var bestVersion = GetVersion(best.ModelId);
var currentVersion = GetVersion(current.ModelId);
if (currentVersion > bestVersion)
{
return current;
}
}
}

_catalogDictionary = dict;
return best;
});

dict[alias] = bestCandidate;
}

_catalogDictionary = dict;
return _catalogDictionary;
}

public async Task<ModelInfo?> GetLatestModelInfoAsync(string aliasOrModelId, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(aliasOrModelId))
{
return null;
}

var catalog = await GetCatalogDictAsync(ct);

// If alias or id without version
if (!aliasOrModelId.Contains(':'))
{
// If exact match in catalog, return it directly
if (catalog.TryGetValue(aliasOrModelId, out var model))
{
return model;
}

// Otherwise, GetModelInfoAsync will get the latest version
return await GetModelInfoAsync(aliasOrModelId, ct);
}
else
{
// If ID with version, strip version and use GetModelInfoAsync to get the latest version
var idWithoutVersion = aliasOrModelId.Split(':')[0];
return await GetModelInfoAsync(idWithoutVersion, ct);
}
}

/// <summary>
/// Extracts the numeric version from a model ID string (e.g. "model-x:3" → 3).
/// </summary>
/// <param name="modelId">The model ID string.</param>
/// <returns>The numeric version, or -1 if not found.</returns>
public static int GetVersion(string modelId)
{
if (string.IsNullOrEmpty(modelId))
{
return -1;
}

var parts = modelId.Split(':');
if (parts.Length == 0)
{
return -1;
}

var versionPart = parts[^1]; // last element
if (int.TryParse(versionPart, out var version))
{
return version;
}

return -1;
}

private static async Task<Uri?> EnsureServiceRunning(CancellationToken ct = default)
{
var startInfo = new ProcessStartInfo
Expand Down
35 changes: 35 additions & 0 deletions sdk/cs/src/FoundryModelInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ public record ModelInfo

[JsonPropertyName("parentModelUri")]
public string ParentModelUri { get; init; } = default!;

[JsonPropertyName("maxOutputTokens")]
public long MaxOutputTokens { get; init; }

[JsonPropertyName("minFLVersion")]
public string MinFLVersion { get; init; } = default!;
}

internal sealed class DownloadRequest
Expand All @@ -123,7 +129,36 @@ internal sealed class ModelInfo

[JsonPropertyName("IgnorePipeReport")]
public required bool IgnorePipeReport { get; set; }
}

internal sealed class UpgradeRequest
{
internal sealed class UpgradeBody
{
[JsonPropertyName("Name")]
public required string Name { get; set; } = string.Empty;

[JsonPropertyName("Uri")]
public required string Uri { get; set; } = string.Empty;

[JsonPropertyName("Publisher")]
public required string Publisher { get; set; } = string.Empty;

[JsonPropertyName("ProviderType")]
public required string ProviderType { get; set; } = string.Empty;

[JsonPropertyName("PromptTemplate")]
public required PromptTemplate PromptTemplate { get; set; }
}

[JsonPropertyName("model")]
public required UpgradeBody Model { get; set; }

[JsonPropertyName("token")]
public required string Token { get; set; }

[JsonPropertyName("IgnorePipeReport")]
public required bool IgnorePipeReport { get; set; }
}

public record ModelDownloadProgress
Expand Down
2 changes: 1 addition & 1 deletion sdk/cs/src/Microsoft.AI.Foundry.Local.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
</PropertyGroup>

<PropertyGroup>
Expand Down
Loading