Skip to content
Draft
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
4 changes: 4 additions & 0 deletions sdk/cpp/include/model.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ namespace foundry_local {
/// Return true from isCancellationRequested to cancel the in-progress download.
virtual void Download(DownloadProgressCallback onProgress = nullptr,
CancellationCallback isCancellationRequested = nullptr) = 0;
virtual std::vector<std::unique_ptr<IModel>> GetVersions() const = 0;
virtual void Load() = 0;
virtual void Unload() = 0;
virtual void RemoveFromCache() = 0;
Expand Down Expand Up @@ -131,6 +132,8 @@ namespace foundry_local {
const std::filesystem::path& GetPath() const override;
void Download(DownloadProgressCallback onProgress = nullptr,
CancellationCallback isCancellationRequested = nullptr) override;
void Download(DownloadProgressCallback onProgress = nullptr) override;
std::vector<std::unique_ptr<IModel>> GetVersions() const override;
void Load() override;

bool IsLoaded() const override;
Expand Down Expand Up @@ -169,6 +172,7 @@ namespace foundry_local {
CancellationCallback isCancellationRequested = nullptr) override {
SelectedVariant().Download(std::move(onProgress), std::move(isCancellationRequested));
}
std::vector<std::unique_ptr<IModel>> GetVersions() const override { return SelectedVariant().GetVersions(); }
void Load() override { SelectedVariant().Load(); }
void Unload() override { SelectedVariant().Unload(); }
void RemoveFromCache() override { SelectedVariant().RemoveFromCache(); }
Expand Down
34 changes: 34 additions & 0 deletions sdk/cpp/src/model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
#include <utility>

#include <gsl/span>
#include <nlohmann/json.hpp>

#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 {
Expand Down Expand Up @@ -110,6 +112,38 @@ namespace foundry_local {
}
}

std::vector<std::unique_ptr<IModel>> 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<ModelInfo> 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<std::unique_ptr<IModel>> result;
result.reserve(infos.size());
for (auto& mi : infos) {
result.emplace_back(std::make_unique<ModelVariant>(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_);
Expand Down
58 changes: 58 additions & 0 deletions sdk/cpp/test/e2e_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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*>(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<ModelVariant*>(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<ModelVariant*>(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
// ===========================================================================
Expand Down
6 changes: 6 additions & 0 deletions sdk/cs/src/Detail/Model.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public async Task DownloadAsync(Action<float>? downloadProgress = null,
await SelectedVariant.DownloadAsync(downloadProgress, ct).ConfigureAwait(false);
}

public async Task<IReadOnlyList<IModel>> 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);
Expand Down
51 changes: 51 additions & 0 deletions sdk/cs/src/Detail/ModelVariant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace Microsoft.AI.Foundry.Local;

using System.Globalization;

using System.Text.Json;

using Microsoft.AI.Foundry.Local.Detail;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -67,6 +69,15 @@ public async Task DownloadAsync(Action<float>? downloadProgress = null,
await Utils.CallWithExceptionHandling(() => DownloadImplAsync(downloadProgress, ct),
$"Error downloading model {Id}", _logger)
.ConfigureAwait(false);
$"Error downloading model {Id}", _logger)
.ConfigureAwait(false);
}

public async Task<IReadOnlyList<IModel>> 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)
Expand Down Expand Up @@ -185,6 +196,46 @@ private async Task DownloadImplAsync(Action<float>? downloadProgress = null,
}
}

private async Task<IReadOnlyList<IModel>> GetVersionsImplAsync(CancellationToken? ct = null)
{
/*var request = new CoreInteropRequest { Params = new Dictionary<string, string> { { "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<string, string> { { "Model", Id } } };
Expand Down
7 changes: 7 additions & 0 deletions sdk/cs/src/IModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ public interface IModel
Task DownloadAsync(Action<float>? downloadProgress = null,
CancellationToken? ct = null);

/// <summary>
/// Gets all the published versions of this specific variant, ordered by version descending (latest first).
/// </summary>
/// <param name="ct">Optional cancellation token.</param>
/// <returns> List including the current/latest versions.</returns>
Task<IReadOnlyList<IModel>> GetVersionsAsync(CancellationToken? ct = null);

/// <summary>
/// Gets the model path if cached.
/// </summary>
Expand Down
29 changes: 29 additions & 0 deletions sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
4 changes: 4 additions & 0 deletions sdk/python/src/detail/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,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()
25 changes: 25 additions & 0 deletions sdk/python/src/detail/model_variant.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,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]
9 changes: 9 additions & 0 deletions sdk/python/src/imodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,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:
"""
Expand Down
41 changes: 41 additions & 0 deletions sdk/python/test/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

from foundry_local_sdk.detail.model_variant import ModelVariant

import os

from foundry_local_sdk.detail.model_data_types import DeviceType

from .conftest import TEST_MODEL_ALIAS, AUDIO_MODEL_ALIAS


Expand Down Expand Up @@ -164,3 +168,40 @@ def execute_command_with_callback(
variant.download(progress_callback=progress.append)

assert progress == [12.5]

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()
Loading