From 84c734e7b74279302e522b0890b0ab8699f3dd89 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:51:27 -0700 Subject: [PATCH] Add feature get all or specific model versions in sdk --- sdk/cpp/include/model.h | 3 + sdk/cpp/src/model.cpp | 34 +++++++++++ sdk/cpp/test/e2e_test.cpp | 58 +++++++++++++++++++ sdk/cs/src/Detail/Model.cs | 6 ++ sdk/cs/src/Detail/ModelVariant.cs | 49 ++++++++++++++++ sdk/cs/src/IModel.cs | 7 +++ .../test/FoundryLocal.Tests/CatalogTests.cs | 29 ++++++++++ sdk/python/src/detail/model.py | 4 ++ sdk/python/src/detail/model_variant.py | 25 ++++++++ sdk/python/src/imodel.py | 9 +++ sdk/python/test/test_model.py | 41 +++++++++++++ 11 files changed, 265 insertions(+) diff --git a/sdk/cpp/include/model.h b/sdk/cpp/include/model.h index b52fae76c..637e702ec 100644 --- a/sdk/cpp/include/model.h +++ b/sdk/cpp/include/model.h @@ -44,6 +44,7 @@ namespace foundry_local { virtual bool IsCached() const = 0; virtual const std::filesystem::path& GetPath() const = 0; virtual void Download(DownloadProgressCallback onProgress = nullptr) = 0; + virtual std::vector> GetVersions() const = 0; virtual void Load() = 0; virtual void Unload() = 0; virtual void RemoveFromCache() = 0; @@ -124,6 +125,7 @@ namespace foundry_local { const ModelInfo& GetInfo() const; const std::filesystem::path& GetPath() const override; void Download(DownloadProgressCallback onProgress = nullptr) override; + std::vector> GetVersions() const override; void Load() override; bool IsLoaded() const override; @@ -161,6 +163,7 @@ namespace foundry_local { void Download(DownloadProgressCallback onProgress = nullptr) override { SelectedVariant().Download(std::move(onProgress)); } + std::vector> GetVersions() const override { return SelectedVariant().GetVersions(); } void Load() override { SelectedVariant().Load(); } void Unload() override { SelectedVariant().Unload(); } void RemoveFromCache() override { SelectedVariant().RemoveFromCache(); } diff --git a/sdk/cpp/src/model.cpp b/sdk/cpp/src/model.cpp index e09f55414..7b7eb86b3 100644 --- a/sdk/cpp/src/model.cpp +++ b/sdk/cpp/src/model.cpp @@ -10,11 +10,13 @@ #include #include +#include #include "foundry_local.h" #include "foundry_local_internal_core.h" #include "foundry_local_exception.h" #include "core_helpers.h" +#include "parser.h" #include "logger.h" namespace foundry_local { @@ -115,6 +117,38 @@ namespace foundry_local { } } + std::vector> ModelVariant::GetVersions() const { + // Ask Core for all published versions of this variant (filtered server-side by variant name). + nlohmann::json params = { + {"Alias", info_.alias}, + {"VariantName", info_.name}, + }; + auto response = CallWithParams(core_, "get_model_versions", params, *logger_); + if (response.HasError()) { + throw Exception("Error getting versions for model [" + info_.name + "]: " + response.error, *logger_); + } + + auto arr = nlohmann::json::parse(response.data); + std::vector infos; + infos.reserve(arr.size()); + for (const auto& j : arr) { + ModelInfo mi; + from_json(j, mi); + infos.emplace_back(std::move(mi)); + } + + // Sort by version descending (latest first). + std::sort(infos.begin(), infos.end(), + [](const ModelInfo& a, const ModelInfo& b) { return a.version > b.version; }); + + std::vector> result; + result.reserve(infos.size()); + for (auto& mi : infos) { + result.emplace_back(std::make_unique(core_, std::move(mi), logger_)); + } + return result; + } + const std::filesystem::path& ModelVariant::GetPath() const { if (cachedPath_.empty()) { auto response = CallWithJson(core_, "get_model_path", MakeModelParams(info_.name), *logger_); diff --git a/sdk/cpp/test/e2e_test.cpp b/sdk/cpp/test/e2e_test.cpp index b49626120..3120392b2 100644 --- a/sdk/cpp/test/e2e_test.cpp +++ b/sdk/cpp/test/e2e_test.cpp @@ -337,6 +337,64 @@ auto& catalog = Manager::Instance().GetCatalog(); EXPECT_FALSE(target->IsLoaded()); } +// =========================================================================== +// GetVersions: pick an older version of a CPU variant +// Mirrors C# CatalogTests.GetVersions_PickOlder_Works +// =========================================================================== + +TEST_F(EndToEndTest, DISABLED_GetVersions_PickOlder_Works) { + if (IsRunningInCI()) { + GTEST_SKIP() << "Skipped in CI (requires model download)"; + } + + auto& catalog = Manager::Instance().GetCatalog(); + auto* model = catalog.GetModel("qwen2.5-0.5b"); + ASSERT_NE(nullptr, model); + + // Pick the CPU variant (latest version, selected by default). + auto* concreteModel = dynamic_cast(model); + ASSERT_NE(nullptr, concreteModel); + + const ModelVariant* cpu = nullptr; + for (const auto& v : concreteModel->GetVariants()) { + if (v.GetInfo().runtime.has_value() && + v.GetInfo().runtime->device_type == DeviceType::CPU) { + cpu = &v; + break; + } + } + ASSERT_NE(nullptr, cpu) << "Model qwen2.5-0.5b should expose a CPU variant"; + + // Discover all published versions of THIS variant. + auto cpuVersions = cpu->GetVersions(); + ASSERT_FALSE(cpuVersions.empty()); + for (const auto& v : cpuVersions) { + auto* mv = dynamic_cast(v.get()); + ASSERT_NE(nullptr, mv); + std::cout << " " << mv->GetId() << " (v" << mv->GetVersion() << ")\n"; + } + + // Pick a specific older version (v2 in the C# test). + IModel* cpuV2 = nullptr; + for (const auto& v : cpuVersions) { + auto* mv = dynamic_cast(v.get()); + if (mv != nullptr && mv->GetVersion() == 2u) { + cpuV2 = mv; + break; + } + } + ASSERT_NE(nullptr, cpuV2) << "Expected version 2 to be available for the CPU variant"; + + cpuV2->Download(); + cpuV2->Load(); + + // Verify a chat client can be constructed against the loaded variant. + OpenAIChatClient client(*cpuV2); + (void)client; + + cpuV2->Unload(); +} + // =========================================================================== // Streaming chat // =========================================================================== diff --git a/sdk/cs/src/Detail/Model.cs b/sdk/cs/src/Detail/Model.cs index 03e9321bc..00ac85299 100644 --- a/sdk/cs/src/Detail/Model.cs +++ b/sdk/cs/src/Detail/Model.cs @@ -84,6 +84,12 @@ public async Task DownloadAsync(Action? downloadProgress = null, await SelectedVariant.DownloadAsync(downloadProgress, ct).ConfigureAwait(false); } + public async Task> GetVersionsAsync(CancellationToken? ct = null) + { + return await SelectedVariant.GetVersionsAsync(ct).ConfigureAwait(false); + } + //=> SelectedVariant.GetVersionsAsync(ct); + public async Task LoadAsync(CancellationToken? ct = null) { await SelectedVariant.LoadAsync(ct).ConfigureAwait(false); diff --git a/sdk/cs/src/Detail/ModelVariant.cs b/sdk/cs/src/Detail/ModelVariant.cs index 250c601a2..d342c6989 100644 --- a/sdk/cs/src/Detail/ModelVariant.cs +++ b/sdk/cs/src/Detail/ModelVariant.cs @@ -6,6 +6,8 @@ namespace Microsoft.AI.Foundry.Local; +using System.Text.Json; + using Microsoft.AI.Foundry.Local.Detail; using Microsoft.Extensions.Logging; @@ -67,6 +69,13 @@ await Utils.CallWithExceptionHandling(() => DownloadImplAsync(downloadProgress, .ConfigureAwait(false); } + public async Task> GetVersionsAsync(CancellationToken? ct = null) + { + return await Utils.CallWithExceptionHandling(() => GetVersionsImplAsync(ct), + "Error getting versions for model", _logger) + .ConfigureAwait(false); + } + public async Task LoadAsync(CancellationToken? ct = null) { await Utils.CallWithExceptionHandling(() => _modelLoadManager.LoadAsync(Id, ct), @@ -169,6 +178,46 @@ private async Task DownloadImplAsync(Action? downloadProgress = null, } } + private async Task> GetVersionsImplAsync(CancellationToken? ct = null) + { + /*var request = new CoreInteropRequest { Params = new Dictionary { { "Model", Id } } }; + var result = await _coreInterop.ExecuteCommandAsync("get_model_path", request, ct).ConfigureAwait(false); + if (result.Error != null) + { + throw new FoundryLocalException( + $"Error getting path for model {Id}: {result.Error}. Has it been downloaded?"); + } + + var path = result.Data!; + return path;*/ + var request = new CoreInteropRequest + { + Params = new() + { + { "Alias", Info.Alias }, + { "VariantName", Info.Name }, // Server-side filtering by variant name + } + }; + + var result = await _coreInterop.ExecuteCommandAsync("get_model_versions", request, ct); + if (result.Error != null) + { + throw new FoundryLocalException($"Error getting versions: {result.Error}", _logger); + } + + var models = JsonSerializer.Deserialize(result.Data!, JsonSerializationContext.Default.ListModelInfo); + if (models == null) + { + return []; + } + + // Sort by version descending (Core already filtered by variant name) + return models + .OrderByDescending(m => m.Version) + .Select(info => (IModel)new ModelVariant(info, _modelLoadManager, _coreInterop, _logger)) + .ToList(); + } + private async Task RemoveFromCacheImplAsync(CancellationToken? ct = null) { var request = new CoreInteropRequest { Params = new Dictionary { { "Model", Id } } }; diff --git a/sdk/cs/src/IModel.cs b/sdk/cs/src/IModel.cs index 372497820..05a2460a6 100644 --- a/sdk/cs/src/IModel.cs +++ b/sdk/cs/src/IModel.cs @@ -31,6 +31,13 @@ public interface IModel Task DownloadAsync(Action? downloadProgress = null, CancellationToken? ct = null); + /// + /// Gets all the published versions of this specific variant, ordered by version descending (latest first). + /// + /// Optional cancellation token. + /// List including the current/latest versions. + Task> GetVersionsAsync(CancellationToken? ct = null); + /// /// Gets the model path if cached. /// diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs index d270ac158..63535ff0c 100644 --- a/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs @@ -118,4 +118,33 @@ public async Task GetLatestVersion_Works() var result4 = await catalog.GetLatestVersionAsync(model); await Assert.That(result4).IsEqualTo(model); } + + [Test] + [SkipUnlessIntegration] + public async Task GetVersions_PickOlderVersion_Works() + { + var manager = FoundryLocalManager.Instance; // initialized by Utils + var catalog = await manager.GetCatalogAsync(); + + var model = await catalog.GetModelAsync("qwen2.5-0.5b"); + await Assert.That(model).IsNotNull(); + + // Pick the CPU variant (latest version, selected by default) + var cpu = model!.Variants.First(v => v.Info.Runtime?.DeviceType == DeviceType.CPU); + + // Discover all published versions of THIS variant + var cpuVersions = await cpu.GetVersionsAsync(); + foreach (var v in cpuVersions) + { + Console.WriteLine($" {v.Id} (v{v.Info.Version})"); + } + + // Pick a specific older version + var cpuV1 = cpuVersions.First(v => v.Info.Version == 2); + await cpuV1.DownloadAsync(); + await cpuV1.LoadAsync(); + var client = await cpuV1.GetChatClientAsync(); + + await Assert.That(client).IsNotNull(); + } } diff --git a/sdk/python/src/detail/model.py b/sdk/python/src/detail/model.py index 6d60b7a2f..0c83b4b93 100644 --- a/sdk/python/src/detail/model.py +++ b/sdk/python/src/detail/model.py @@ -146,3 +146,7 @@ def get_audio_client(self) -> AudioClient: def get_embedding_client(self) -> EmbeddingClient: """Get an embedding client for the currently selected variant.""" return self._selected_variant.get_embedding_client() + + def get_versions(self) -> List[IModel]: + """Get all published versions of the currently selected variant.""" + return self._selected_variant.get_versions() diff --git a/sdk/python/src/detail/model_variant.py b/sdk/python/src/detail/model_variant.py index 76efb05cd..400656c24 100644 --- a/sdk/python/src/detail/model_variant.py +++ b/sdk/python/src/detail/model_variant.py @@ -175,3 +175,28 @@ def get_audio_client(self) -> AudioClient: def get_embedding_client(self) -> EmbeddingClient: """Create an OpenAI-compatible ``EmbeddingClient`` for this variant.""" return EmbeddingClient(self.id, self._core_interop) + + def get_versions(self) -> List[IModel]: + """Get all published versions of this variant, ordered by version descending (latest first). + + Returns: + List of ``IModel`` instances, one per published version of this variant. + """ + import json + + request = InteropRequest(params={ + "Alias": self._model_info.alias, + "VariantName": self._model_info.name, # Server-side filtering by variant name + }) + response = self._core_interop.execute_command("get_model_versions", request) + if response.error is not None: + raise FoundryLocalException(f"Failed to get model versions: {response.error}") + + parsed = json.loads(response.data) + if not isinstance(parsed, list): + raise FoundryLocalException("Expected a JSON array from get_model_versions.") + + infos = [ModelInfo.model_validate(item) for item in parsed] + # Sort by version descending (Core already filtered by variant name). + infos.sort(key=lambda mi: mi.version, reverse=True) + return [ModelVariant(mi, self._model_load_manager, self._core_interop) for mi in infos] diff --git a/sdk/python/src/imodel.py b/sdk/python/src/imodel.py index f723e514a..f34936db6 100644 --- a/sdk/python/src/imodel.py +++ b/sdk/python/src/imodel.py @@ -142,6 +142,15 @@ def variants(self) -> List['IModel']: """Variants of the model that are available. Variants of the model are optimized for different devices.""" pass + @abstractmethod + def get_versions(self) -> List['IModel']: + """ + Get all published versions of this specific variant, ordered by version descending (latest first). + + :return: List of IModel instances, one per published version of this variant. + """ + pass + @abstractmethod def select_variant(self, variant: 'IModel') -> None: """ diff --git a/sdk/python/test/test_model.py b/sdk/python/test/test_model.py index e2ea15090..c64e88395 100644 --- a/sdk/python/test/test_model.py +++ b/sdk/python/test/test_model.py @@ -6,6 +6,10 @@ from __future__ import annotations +import os + +from foundry_local_sdk.detail.model_data_types import DeviceType + from .conftest import TEST_MODEL_ALIAS, AUDIO_MODEL_ALIAS @@ -86,3 +90,40 @@ def test_should_expose_supports_tool_calling(self, catalog): assert model is not None stc = model.supports_tool_calling assert stc is None or isinstance(stc, bool) + + def test_get_versions_pick_older_works(self, catalog): + """Mirrors C# CatalogTests.GetVersions_PickOlder_Works. + + Pick the CPU variant of the test model, discover all published versions, + pick an older version, then download/load and obtain a chat client. + """ + + model = catalog.get_model(TEST_MODEL_ALIAS) + assert model is not None + + # Pick the CPU variant (latest version, selected by default). + cpu = next( + (v for v in model.variants + if v.info.runtime is not None and v.info.runtime.device_type == DeviceType.CPU), + None, + ) + assert cpu is not None, f"{TEST_MODEL_ALIAS} should expose a CPU variant" + + # Discover all published versions of THIS variant. + cpu_versions = cpu.get_versions() + assert len(cpu_versions) > 0 + for v in cpu_versions: + print(f" {v.id} (v{v.info.version})") + + # Pick a specific older version (v2, matching the C# test). + cpu_v2 = next((v for v in cpu_versions if v.info.version == 2), None) + assert cpu_v2 is not None, "Expected version 2 to be available for the CPU variant" + + cpu_v2.download() + try: + cpu_v2.load() + client = cpu_v2.get_chat_client() + assert client is not None + finally: + if cpu_v2.is_loaded: + cpu_v2.unload()