@@ -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
0 commit comments