Skip to content

Commit 3773169

Browse files
authored
Add support for model versioning (#188)
* Add JS isModelUpgradable and upgradeModel * small updates to JS upgrade API * Add Python upgrade_model and is_model_upgradable APIs * consistent apis * Add C# APIs * add rust APIs * update python API to support versioning * remove redundant code * update JS API to support versioning * update C# API to support versioning * update Rust API to support versioning * cargo format * update hello-foundry-local sample, make new Response properties optional * Update model used in js/hello-foundry-local * Update _get_latest_model_info implementation
1 parent 394ca51 commit 3773169

File tree

23 files changed

+1272
-177
lines changed

23 files changed

+1272
-177
lines changed

samples/js/hello-foundry-local/src/app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { FoundryLocalManager } from "foundry-local-sdk";
88
// to your end-user's device.
99
// TIP: You can find a list of available models by running the
1010
// following command in your terminal: `foundry model list`.
11-
const alias = "phi-3.5-mini";
11+
const alias = "qwen2.5-coder-0.5b-instruct-generic-cpu:3";
1212

1313
// Create a FoundryLocalManager instance. This will start the Foundry
1414
// Local service if it is not already running.

samples/python/hello-foundry-local/src/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

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

1111
# Create a FoundryLocalManager instance. This will start the Foundry
1212
# Local service if it is not already running and load the specified model.

sdk/cs/src/FoundryLocalManager.cs

Lines changed: 184 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,38 @@ public void RefreshCatalog()
171171

172172
public async Task<ModelInfo?> GetModelInfoAsync(string aliasOrModelId, CancellationToken ct = default)
173173
{
174-
var dictionary = await GetCatalogDictAsync(ct);
174+
var catalog = await GetCatalogDictAsync(ct);
175+
ModelInfo? modelInfo = null;
175176

176-
dictionary.TryGetValue(aliasOrModelId, out ModelInfo? model);
177-
return model;
177+
// Direct match (id with version or alias)
178+
if (catalog.TryGetValue(aliasOrModelId, out var directMatch))
179+
{
180+
modelInfo = directMatch;
181+
}
182+
else if (!aliasOrModelId.Contains(':'))
183+
{
184+
// If no direct match and aliasOrModelId does not contain a version suffix
185+
var prefix = aliasOrModelId + ":";
186+
var bestVersion = -1;
187+
188+
foreach (var kvp in catalog)
189+
{
190+
var key = kvp.Key;
191+
ModelInfo model = kvp.Value;
192+
193+
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
194+
{
195+
var version = GetVersion(key);
196+
if (version > bestVersion)
197+
{
198+
bestVersion = version;
199+
modelInfo = model;
200+
}
201+
}
202+
}
203+
}
204+
205+
return modelInfo;
178206
}
179207

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

276+
public async Task<bool> IsModelUpgradeableAsync(string aliasOrModelId, CancellationToken ct = default)
277+
{
278+
var modelInfo = await GetLatestModelInfoAsync(aliasOrModelId, ct);
279+
if (modelInfo == null)
280+
{
281+
return false; // Model not found in the catalog
282+
}
283+
284+
var latestVersion = GetVersion(modelInfo.ModelId);
285+
if (latestVersion == -1)
286+
{
287+
return false; // Invalid version format in model ID
288+
}
289+
290+
var cachedModels = await ListCachedModelsAsync(ct);
291+
foreach (var cachedModel in cachedModels)
292+
{
293+
if (cachedModel.ModelId.Equals(modelInfo.ModelId, StringComparison.OrdinalIgnoreCase) &&
294+
GetVersion(cachedModel.ModelId) == latestVersion)
295+
{
296+
// Cached model is already at latest version
297+
return false;
298+
}
299+
}
300+
301+
// Latest version not in cache - upgrade available
302+
return true;
303+
304+
}
305+
306+
public async Task<ModelInfo?> UpgradeModelAsync(string aliasOrModelId, string? token = null, CancellationToken ct = default)
307+
{
308+
// Get the latest model info; throw if not found
309+
var modelInfo = await GetLatestModelInfoAsync(aliasOrModelId, ct)
310+
?? throw new ArgumentException($"Model '{aliasOrModelId}' was not found in the catalog.");
311+
312+
// Attempt to download the model
313+
try
314+
{
315+
return await DownloadModelAsync(modelInfo.ModelId, token, false, ct);
316+
}
317+
catch (Exception ex)
318+
{
319+
throw new InvalidOperationException($"Failed to upgrade model '{aliasOrModelId}'.", ex);
320+
}
321+
}
322+
248323
public async Task<ModelInfo> LoadModelAsync(string aliasOrModelId, TimeSpan? timeout = null, CancellationToken ct = default)
249324
{
250325
var modelInfo = await GetModelInfoAsync(aliasOrModelId, ct) ?? throw new InvalidOperationException($"Model {aliasOrModelId} not found in catalog.");
@@ -435,39 +510,125 @@ private async Task<List<ModelInfo>> FetchModelInfosAsync(IEnumerable<string> ali
435510

436511
private async Task<Dictionary<string, ModelInfo>> GetCatalogDictAsync(CancellationToken ct = default)
437512
{
438-
if (_catalogDictionary == null)
513+
if (_catalogDictionary != null)
514+
{
515+
return _catalogDictionary;
516+
}
517+
518+
var dict = new Dictionary<string, ModelInfo>(StringComparer.OrdinalIgnoreCase);
519+
var models = await ListCatalogModelsAsync(ct);
520+
foreach (var model in models)
521+
{
522+
dict[model.ModelId] = model;
523+
}
524+
525+
var aliasCandidates = new Dictionary<string, List<ModelInfo>>(StringComparer.OrdinalIgnoreCase);
526+
foreach (var model in models)
439527
{
440-
var dict = new Dictionary<string, ModelInfo>(StringComparer.OrdinalIgnoreCase);
441-
var models = await ListCatalogModelsAsync(ct);
442-
foreach (var model in models)
528+
if (!string.IsNullOrWhiteSpace(model.Alias))
443529
{
444-
dict[model.ModelId] = model;
530+
if (!aliasCandidates.TryGetValue(model.Alias, out var list))
531+
{
532+
list = [];
533+
aliasCandidates[model.Alias] = list;
534+
}
535+
list.Add(model);
536+
}
537+
}
538+
539+
// For each alias, choose the best candidate based on _priorityMap and version
540+
foreach (var kvp in aliasCandidates)
541+
{
542+
var alias = kvp.Key;
543+
List<ModelInfo> candidates = kvp.Value;
445544

446-
if (!string.IsNullOrWhiteSpace(model.Alias))
545+
ModelInfo bestCandidate = candidates.Aggregate((best, current) =>
546+
{
547+
// Get priorities or max int if not found
548+
var bestPriority = _priorityMap.TryGetValue(best.Runtime.ExecutionProvider, out var bp) ? bp : int.MaxValue;
549+
var currentPriority = _priorityMap.TryGetValue(current.Runtime.ExecutionProvider, out var cp) ? cp : int.MaxValue;
550+
551+
if (currentPriority < bestPriority)
447552
{
448-
if (!dict.TryGetValue(model.Alias, out var existing))
449-
{
450-
dict[model.Alias] = model;
451-
}
452-
else
453-
{
454-
var currentPriority = _priorityMap.TryGetValue(model.Runtime.ExecutionProvider, out var cp) ? cp : int.MaxValue;
455-
var existingPriority = _priorityMap.TryGetValue(existing.Runtime.ExecutionProvider, out var ep) ? ep : int.MaxValue;
553+
return current;
554+
}
456555

457-
if (currentPriority < existingPriority)
458-
{
459-
dict[model.Alias] = model;
460-
}
556+
if (currentPriority == bestPriority)
557+
{
558+
var bestVersion = GetVersion(best.ModelId);
559+
var currentVersion = GetVersion(current.ModelId);
560+
if (currentVersion > bestVersion)
561+
{
562+
return current;
461563
}
462564
}
463-
}
464565

465-
_catalogDictionary = dict;
566+
return best;
567+
});
568+
569+
dict[alias] = bestCandidate;
466570
}
467571

572+
_catalogDictionary = dict;
468573
return _catalogDictionary;
469574
}
470575

576+
public async Task<ModelInfo?> GetLatestModelInfoAsync(string aliasOrModelId, CancellationToken ct = default)
577+
{
578+
if (string.IsNullOrEmpty(aliasOrModelId))
579+
{
580+
return null;
581+
}
582+
583+
var catalog = await GetCatalogDictAsync(ct);
584+
585+
// If alias or id without version
586+
if (!aliasOrModelId.Contains(':'))
587+
{
588+
// If exact match in catalog, return it directly
589+
if (catalog.TryGetValue(aliasOrModelId, out var model))
590+
{
591+
return model;
592+
}
593+
594+
// Otherwise, GetModelInfoAsync will get the latest version
595+
return await GetModelInfoAsync(aliasOrModelId, ct);
596+
}
597+
else
598+
{
599+
// If ID with version, strip version and use GetModelInfoAsync to get the latest version
600+
var idWithoutVersion = aliasOrModelId.Split(':')[0];
601+
return await GetModelInfoAsync(idWithoutVersion, ct);
602+
}
603+
}
604+
605+
/// <summary>
606+
/// Extracts the numeric version from a model ID string (e.g. "model-x:3" → 3).
607+
/// </summary>
608+
/// <param name="modelId">The model ID string.</param>
609+
/// <returns>The numeric version, or -1 if not found.</returns>
610+
public static int GetVersion(string modelId)
611+
{
612+
if (string.IsNullOrEmpty(modelId))
613+
{
614+
return -1;
615+
}
616+
617+
var parts = modelId.Split(':');
618+
if (parts.Length == 0)
619+
{
620+
return -1;
621+
}
622+
623+
var versionPart = parts[^1]; // last element
624+
if (int.TryParse(versionPart, out var version))
625+
{
626+
return version;
627+
}
628+
629+
return -1;
630+
}
631+
471632
private static async Task<Uri?> EnsureServiceRunning(CancellationToken ct = default)
472633
{
473634
var startInfo = new ProcessStartInfo

sdk/cs/src/FoundryModelInfo.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ public record ModelInfo
9999

100100
[JsonPropertyName("parentModelUri")]
101101
public string ParentModelUri { get; init; } = default!;
102+
103+
[JsonPropertyName("maxOutputTokens")]
104+
public long MaxOutputTokens { get; init; }
105+
106+
[JsonPropertyName("minFLVersion")]
107+
public string MinFLVersion { get; init; } = default!;
102108
}
103109

104110
internal sealed class DownloadRequest
@@ -123,7 +129,36 @@ internal sealed class ModelInfo
123129

124130
[JsonPropertyName("IgnorePipeReport")]
125131
public required bool IgnorePipeReport { get; set; }
132+
}
133+
134+
internal sealed class UpgradeRequest
135+
{
136+
internal sealed class UpgradeBody
137+
{
138+
[JsonPropertyName("Name")]
139+
public required string Name { get; set; } = string.Empty;
140+
141+
[JsonPropertyName("Uri")]
142+
public required string Uri { get; set; } = string.Empty;
143+
144+
[JsonPropertyName("Publisher")]
145+
public required string Publisher { get; set; } = string.Empty;
146+
147+
[JsonPropertyName("ProviderType")]
148+
public required string ProviderType { get; set; } = string.Empty;
149+
150+
[JsonPropertyName("PromptTemplate")]
151+
public required PromptTemplate PromptTemplate { get; set; }
152+
}
153+
154+
[JsonPropertyName("model")]
155+
public required UpgradeBody Model { get; set; }
126156

157+
[JsonPropertyName("token")]
158+
public required string Token { get; set; }
159+
160+
[JsonPropertyName("IgnorePipeReport")]
161+
public required bool IgnorePipeReport { get; set; }
127162
}
128163

129164
public record ModelDownloadProgress

sdk/cs/src/Microsoft.AI.Foundry.Local.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<ImplicitUsings>enable</ImplicitUsings>
1515
<Nullable>enable</Nullable>
1616
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
17-
<Version>0.1.0</Version>
17+
<Version>0.2.0</Version>
1818
</PropertyGroup>
1919

2020
<PropertyGroup>

0 commit comments

Comments
 (0)