diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h index a32c686fc..7a172bbde 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -868,6 +868,22 @@ struct flCatalogApi { FL_API_STATUS(GetCachedModels, _In_ const flCatalog* catalog, _Outptr_ flModelList** out_models); FL_API_STATUS(GetLoadedModels, _In_ const flCatalog* catalog, _Outptr_ flModelList** out_models); + /// List all known versions of a model (by alias), optionally filtered to a + /// specific variant name. Bypasses the "latest only" filter applied during + /// the normal catalog refresh so older versions visible to the underlying + /// source are returned. + /// + /// `model_alias` is the model alias (e.g. "phi-4-mini"); pass null or an + /// empty string to request all versioned models from the source. + /// `variant_name` optionally narrows the result to a single variant name + /// (e.g. "phi-4-mini-cpu-int4-rtn-block-32-acc-level-4"); pass null or an + /// empty string for all variants. + /// Caller owns the returned model list and must release it via ModelList_Release. + FL_API_STATUS(GetModelVersions, _In_ const flCatalog* catalog, + _In_opt_ const char* model_alias, + _In_opt_ const char* variant_name, + _Outptr_ flModelList** out_models); + // End V1 }; diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h index d8ab4e0a4..9686988f9 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -729,6 +729,13 @@ class ICatalog { virtual std::unique_ptr GetModel(const std::string& alias) const = 0; virtual std::unique_ptr GetModelVariant(const std::string& model_id) const = 0; virtual std::unique_ptr GetLatestVersion(const IModel& model) const = 0; + + /// List all known versions of a model (by alias), optionally filtered to a + /// specific variant name. Bypasses the "latest only" filter that the + /// regular catalog refresh applies. See `flCatalogApi::GetModelVersions` + /// for details. Pass an empty `variant_name` for all variants. + virtual ModelList GetModelVersions(const std::string& model_alias, + const std::string& variant_name = {}) = 0; }; // =========================================================================== @@ -751,6 +758,8 @@ class Catalog final : public ICatalog { std::unique_ptr GetModel(const std::string& alias) const override; std::unique_ptr GetModelVariant(const std::string& model_id) const override; std::unique_ptr GetLatestVersion(const IModel& model) const override; + ModelList GetModelVersions(const std::string& model_alias, + const std::string& variant_name = {}) override; private: detail::Base handle_; diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h index 80402185e..9aae75233 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h @@ -611,6 +611,17 @@ inline std::unique_ptr Catalog::GetLatestVersion(const IModel& model) co return std::make_unique(*m); } +inline ModelList Catalog::GetModelVersions(const std::string& model_alias, + const std::string& variant_name) { + flModelList* models = nullptr; + Check(detail::catalog_api()->GetModelVersions( + handle_.get(), + model_alias.empty() ? nullptr : model_alias.c_str(), + variant_name.empty() ? nullptr : variant_name.c_str(), + &models)); + return ModelList(*models); +} + // =========================================================================== // Item // =========================================================================== diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index 26fb647a9..f4557d2aa 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -674,6 +674,30 @@ FL_API_STATUS_IMPL(Catalog_GetLoadedModelsImpl, const flCatalog* catalog, flMode API_IMPL_END } +FL_API_STATUS_IMPL(Catalog_GetModelVersionsImpl, const flCatalog* catalog, + const char* model_alias, const char* variant_name, + flModelList** out_models) { + API_IMPL_BEGIN + if (!catalog || !out_models) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + std::string alias = model_alias ? model_alias : std::string{}; + std::string variant = variant_name ? variant_name : std::string{}; + + auto models = catalog->impl.GetModelVersions(alias, variant); + auto list = std::make_unique(); + list->items.reserve(models.size()); + + for (auto* m : models) { + list->items.push_back(AsHandle(m)); + } + + *out_models = list.release(); + return nullptr; + API_IMPL_END +} + FL_API_STATUS_IMPL(Catalog_GetNameImpl, const flCatalog* catalog, const char** out_name) { API_IMPL_BEGIN if (!catalog || !out_name) { @@ -693,6 +717,7 @@ static const flCatalogApi g_catalog_api = { Catalog_GetLatestVersionImpl, Catalog_GetCachedModelsImpl, Catalog_GetLoadedModelsImpl, + Catalog_GetModelVersionsImpl, }; // ======================================================================== diff --git a/sdk_v2/cpp/src/catalog.h b/sdk_v2/cpp/src/catalog.h index 8379aa3c1..c8678460e 100644 --- a/sdk_v2/cpp/src/catalog.h +++ b/sdk_v2/cpp/src/catalog.h @@ -31,6 +31,23 @@ class ICatalog { /// Gets the latest version of a model. Returns nullptr if not found. virtual Model* GetLatestVersion(const Model* model) const = 0; + /// Lists all known versions of a model (by alias), optionally filtered to a + /// specific variant name. Bypasses the "latest only" filter the regular + /// catalog refresh applies — new versions discovered by this call are + /// integrated into the catalog's storage so the returned pointers remain + /// valid for the lifetime of the catalog. + /// + /// `model_alias` is the alias of the model (e.g. "phi-4-mini"). When empty, + /// implementations may return all versioned models from the underlying + /// source (still subject to device/EP filtering). + /// `variant_name` optionally narrows results to a specific variant (e.g. + /// "Phi-4-generic-gpu"). Pass an empty string to + /// return every variant. + /// + /// Maps to C# `IModelCatalog.GetModelVersionsAsync`. + virtual std::vector GetModelVersions(const std::string& model_alias, + const std::string& variant_name) = 0; + /// Lists only models that are cached locally. virtual std::vector GetCachedModels() const = 0; diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc index 61da8ffd7..be1fd870d 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +FetchModelVersions// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #include "catalog/azure_model_catalog.h" #include "catalog/catalog_cache.h" @@ -11,6 +11,9 @@ #include #include +#include +#include + namespace fl { AzureModelCatalog::AzureModelCatalog(std::vector>> catalog_urls, @@ -123,4 +126,117 @@ std::vector AzureModelCatalog::FetchModels() const { return models; } +namespace { + +// Build a flat list of (url, filter) endpoints to query for direct-lookup +// helpers (FetchModelVersions / FetchModelsByIds). Honours the configured +// catalog_urls_; falls back to the default URL when none are configured. +std::vector>> EnumerateEndpoints( + const std::vector>>& configured, + const char* default_url, + const char* default_filter) { + if (!configured.empty()) { + return configured; + } + + return {{default_url, std::optional(default_filter)}}; +} + +} // namespace + +std::vector AzureModelCatalog::FetchModelVersions(const std::string& model_alias) const { + if (cache_only_) { + // In cache-only mode we have no remote source to query for older versions. + logger_.Log(LogLevel::Debug, + "FetchModelVersions skipped: catalog is in cache-only mode."); + return {}; + } + + // Scan local models so any version already on disk is reported as cached. + auto local_models = ScanLocalModels(cache_dir_, logger_); + + std::vector models; + const auto endpoints = EnumerateEndpoints(catalog_urls_, kDefaultCatalogUrl, kDefaultCatalogFilter); + + for (const auto& [url, filter] : endpoints) { + try { + auto client = MakeCatalogClient(url, filter.value_or(""), ep_detector_, logger_, cache_dir_); + auto model_infos = client->FetchAllVersionsByAlias(model_alias); + + models.reserve(models.size() + model_infos.size()); + for (auto& info : model_infos) { + std::string local_path; + auto it = local_models.find(info.model_id); + if (it != local_models.end()) { + local_path = it->second; + } + + models.push_back(model_factory_(std::move(info), std::move(local_path))); + } + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Error, + fmt::format("FetchModelVersions: failed to query {} — {}", url, ex.what())); + } + } + + logger_.Log(LogLevel::Information, + fmt::format("FetchModelVersions('{}') returned {} variant(s).", + model_alias, models.size())); + + return models; +} + +std::vector AzureModelCatalog::FetchModelsByIds(const std::vector& model_ids) const { + if (model_ids.empty()) { + return {}; + } + + if (cache_only_) { + logger_.Log(LogLevel::Debug, + "FetchModelsByIds skipped: catalog is in cache-only mode."); + return {}; + } + + auto local_models = ScanLocalModels(cache_dir_, logger_); + + std::vector models; + const auto endpoints = EnumerateEndpoints(catalog_urls_, kDefaultCatalogUrl, kDefaultCatalogFilter); + + // Track which IDs are still unresolved so we can stop calling further + // endpoints once everything has been found. + std::vector remaining(model_ids); + + for (const auto& [url, filter] : endpoints) { + if (remaining.empty()) { + break; + } + + try { + auto client = MakeCatalogClient(url, filter.value_or(""), ep_detector_, logger_, cache_dir_); + auto model_infos = client->FetchModelsByIds(remaining); + + for (auto& info : model_infos) { + std::string local_path; + auto it = local_models.find(info.model_id); + if (it != local_models.end()) { + local_path = it->second; + } + + // Drop this id from the remaining list now that it's resolved. + auto rit = std::find(remaining.begin(), remaining.end(), info.model_id); + if (rit != remaining.end()) { + remaining.erase(rit); + } + + models.push_back(model_factory_(std::move(info), std::move(local_path))); + } + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Error, + fmt::format("FetchModelsByIds: failed to query {} — {}", url, ex.what())); + } + } + + return models; +} + } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.h b/sdk_v2/cpp/src/catalog/azure_model_catalog.h index dbc7502a6..246b3b2a0 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.h @@ -31,6 +31,8 @@ class AzureModelCatalog : public BaseModelCatalog { protected: std::vector FetchModels() const override; + std::vector FetchModelVersions(const std::string& model_alias) const override; + std::vector FetchModelsByIds(const std::vector& model_ids) const override; private: #if defined(FOUNDRY_LOCAL_HAVE_LIVE_CATALOG_CLIENT) diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.cc b/sdk_v2/cpp/src/catalog/base_model_catalog.cc index af8b78cf9..726e92f2d 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.cc @@ -9,6 +9,7 @@ #include #include #include +#include namespace fl { @@ -162,6 +163,84 @@ void BaseModelCatalog::PopulateModels(std::vector variants) const { populated_ = true; } +void BaseModelCatalog::IntegrateVariantsLocked(std::vector variants) const { + if (variants.empty()) { + return; + } + + // Sort the incoming variants so newly-added ones land in priority order + // within their alias group (matches the sort applied by PopulateModels). + std::stable_sort(variants.begin(), variants.end(), CompareModelsForSort); + + // Build a lookup of existing aliases -> containers so we can merge new + // variants in O(1) per incoming variant. + std::unordered_map alias_to_existing; + for (auto& m : models_) { + alias_to_existing[m->Alias()] = m.get(); + } + + // Track existing model_ids in a single set so the dedup check is O(1) and + // doesn't require walking each container's variants per incoming variant. + std::unordered_set existing_ids; + for (auto& m : models_) { + for (auto* v : m->Variants()) { + existing_ids.insert(v->Info().model_id); + } + } + + size_t added_variants = 0; + size_t added_aliases = 0; + + // Collect-by-alias the variants that are actually new (not already known). + std::map> new_by_alias; + for (auto& v : variants) { + const auto& info = v.Info(); + if (info.model_id.empty() || info.alias.empty() || info.name.empty()) { + logger_.Log(LogLevel::Debug, + fmt::format("IntegrateVariants: skipping model with missing required fields: " + "id='{}', name='{}', alias='{}'.", + info.model_id, info.name, info.alias)); + continue; + } + + if (existing_ids.count(info.model_id) > 0) { + continue; + } + + existing_ids.insert(info.model_id); + new_by_alias[info.alias].push_back(std::move(v)); + } + + for (auto& [alias, alias_variants] : new_by_alias) { + auto it = alias_to_existing.find(alias); + if (it != alias_to_existing.end()) { + for (auto& v : alias_variants) { + it->second->AddVariant(std::move(v)); + ++added_variants; + } + } else { + // New alias: build a container in priority order (best first). + auto first = std::move(alias_variants.front()); + auto container = Model::MakeContainer(std::move(first)); + for (size_t i = 1; i < alias_variants.size(); ++i) { + container.AddVariant(std::move(alias_variants[i])); + } + + models_.push_back(std::make_unique(std::move(container))); + ++added_aliases; + added_variants += alias_variants.size(); + } + } + + if (added_variants > 0 || added_aliases > 0) { + logger_.Log(LogLevel::Information, + fmt::format("Catalog '{}' integrated {} new variant(s) across {} new alias(es). " + "{} total alias container(s).", + name_, added_variants, added_aliases, models_.size())); + RebuildIndex(); + } +} + void BaseModelCatalog::RebuildIndex() const { auto new_index = std::make_shared(); @@ -276,6 +355,45 @@ Model* BaseModelCatalog::GetModelVariant(const std::string& model_id) const { return id_it->second; } + // Not in cached indices — try a direct catalog lookup. Only attempt this when + // the input looks like a Model Id (Name + ":" + Version). Plain names and + // aliases would not succeed via FetchModelsByIds and just cost a network call. + // Mirrors C# BaseModelCatalog.GetModelInfoAsync direct-fetch fallback. + if (model_id.find(':') != std::string::npos) { + logger_.Log(LogLevel::Information, + fmt::format("GetModelVariant: '{}' not in cache, fetching from catalog source.", + model_id)); + + std::vector fetched; + try { + fetched = FetchModelsByIds({model_id}); + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Warning, + fmt::format("GetModelVariant: direct fetch for '{}' failed — {}", + model_id, ex.what())); + return nullptr; + } catch (...) { + logger_.Log(LogLevel::Warning, + fmt::format("GetModelVariant: direct fetch for '{}' failed — unknown error", + model_id)); + return nullptr; + } + + if (!fetched.empty()) { + { + std::lock_guard lock(mutex_); + IntegrateVariantsLocked(std::move(fetched)); + } + + // Look up again from the refreshed index. + idx = GetIndex(); + auto id_it2 = idx->id_index.find(model_id); + if (id_it2 != idx->id_index.end()) { + return id_it2->second; + } + } + } + logger_.Log(LogLevel::Information, fmt::format("GetModelVariant: '{}' not found in the catalog.", model_id)); @@ -329,4 +447,61 @@ std::vector BaseModelCatalog::GetLoadedModels() const { return result; } +std::vector BaseModelCatalog::GetModelVersions(const std::string& model_alias, + const std::string& variant_name) { + // Make sure the regular "latest only" catalog is populated first so the + // existing alias container exists to merge into. + EnsurePopulated(); + + std::vector fetched; + try { + fetched = FetchModelVersions(model_alias); + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Warning, + fmt::format("GetModelVersions: fetch for alias '{}' failed — {}", + model_alias, ex.what())); + return {}; + } catch (...) { + logger_.Log(LogLevel::Warning, + fmt::format("GetModelVersions: fetch for alias '{}' failed — unknown error", + model_alias)); + return {}; + } + + if (!fetched.empty()) { + std::lock_guard lock(mutex_); + IntegrateVariantsLocked(std::move(fetched)); + } + + auto idx = GetIndex(); + std::vector result; + + if (!model_alias.empty()) { + auto alias_it = idx->alias_index.find(model_alias); + if (alias_it == idx->alias_index.end()) { + logger_.Log(LogLevel::Information, + fmt::format("GetModelVersions: alias '{}' not found in catalog.", model_alias)); + return {}; + } + + for (auto* variant : alias_it->second->Variants()) { + if (variant_name.empty() || variant->Info().name == variant_name) { + result.push_back(variant); + } + } + } else { + // No alias filter: walk all alias containers. + std::lock_guard lock(mutex_); + for (auto& m : models_) { + for (auto* variant : m->Variants()) { + if (variant_name.empty() || variant->Info().name == variant_name) { + result.push_back(variant); + } + } + } + } + + return result; +} + } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.h b/sdk_v2/cpp/src/catalog/base_model_catalog.h index ad74d2f89..ac1cb7689 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.h @@ -38,6 +38,8 @@ class BaseModelCatalog : public ICatalog { Model* GetLatestVersion(const Model* model) const override; std::vector GetCachedModels() const override; std::vector GetLoadedModels() const override; + std::vector GetModelVersions(const std::string& model_alias, + const std::string& variant_name) override; void InvalidateCache() override; protected: @@ -46,6 +48,18 @@ class BaseModelCatalog : public ICatalog { /// Maps to C# FetchModelInfoAsync. virtual std::vector FetchModels() const = 0; + /// Derived classes implement this to fetch all versions of a model from the + /// underlying catalog source, bypassing the "latest only" filter. + /// Returns the variants (in any order; the base class sorts/indexes them). + /// Maps to C# `BaseModelCatalog.GetModelVersionsAsync` -> derived overrides. + virtual std::vector FetchModelVersions(const std::string& model_alias) const = 0; + + /// Derived classes implement this to look up specific model versions by ID + /// from the underlying catalog source (e.g., older versions not in the + /// latest catalog). Empty list if `model_ids` is empty. + /// Maps to C# `BaseModelCatalog.FetchLocalModelsAsync`. + virtual std::vector FetchModelsByIds(const std::vector& model_ids) const = 0; + private: /// Lookup indices into the stable models_ storage. /// Rebuilt on refresh. Does not own any Model instances. @@ -79,6 +93,13 @@ class BaseModelCatalog : public ICatalog { /// Populate or refresh the catalog (under lock). Groups variants, builds indices. void PopulateModels(std::vector variants) const; + /// Merge new variants into the catalog's stable storage (under lock). For an + /// existing alias container, appends any variants whose model_id isn't already + /// present. For new aliases, creates a new container. Rebuilds the lookup + /// index when the model set actually changed. + /// Caller must hold `mutex_`. + void IntegrateVariantsLocked(std::vector variants) const; + /// Build lookup indices from the current models_ collection. /// Builds a complete new ModelIndex locally, then atomically swaps it into index_. void RebuildIndex() const; diff --git a/sdk_v2/cpp/src/catalog/catalog_client.h b/sdk_v2/cpp/src/catalog/catalog_client.h index d560cbe59..93ed78ce2 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.h +++ b/sdk_v2/cpp/src/catalog/catalog_client.h @@ -27,6 +27,24 @@ class ICatalogClient { /// way to resolve arbitrary IDs (e.g., the static snapshot client) return {}. virtual std::vector FetchModelsByIds( const std::vector& model_ids) = 0; + + /// Fetch all versions of a model (by alias), bypassing the "latest only" + /// filter that `FetchAllModelInfos` applies. Maps to C# + /// `IAzureFoundryApiService.FetchAllModelVersionsAsync`. + /// + /// `model_alias` is optional — when empty, implementations may return all + /// available versioned models (still subject to device/EP filtering). + /// Implementations that cannot list older versions (e.g., the static + /// snapshot client) return only whatever versions are available locally + /// to them. + /// + /// A default implementation is provided that returns {} so existing + /// implementations (notably the private live catalog client) continue to + /// build without modification. + virtual std::vector FetchAllVersionsByAlias( + const std::string& /*model_alias*/) { + return {}; + } }; /// Production helper that combines a catalog fetch with locally cached model diff --git a/sdk_v2/cpp/src/catalog/static_catalog_client.cc b/sdk_v2/cpp/src/catalog/static_catalog_client.cc index af9ebb5f6..23fcbe15c 100644 --- a/sdk_v2/cpp/src/catalog/static_catalog_client.cc +++ b/sdk_v2/cpp/src/catalog/static_catalog_client.cc @@ -71,6 +71,33 @@ class StaticCatalogClient : public ICatalogClient { return {}; } + std::vector FetchAllVersionsByAlias( + const std::string& model_alias) override { + // NOTE: this does NOT return every historical version of `model_alias`. + // The embedded snapshot is a single point-in-time capture of the Azure + // catalog (generated by tools/catalog_snapshot at release time), so it + // only contains the versions that were current when the snapshot was + // taken — older, retired versions are not in the bytes and cannot be + // synthesized. We filter the snapshot by alias and return whatever rows + // are present (typically one per EP+device variant, occasionally a few + // when variants drift to different versions). + // + // For full historical version coverage, use a live ICatalogClient + // (the private AzureCatalogClient, gated by + // FOUNDRY_LOCAL_HAVE_LIVE_CATALOG_CLIENT) which queries Azure with the + // all-versions search filter. + auto models = FetchAllModelInfos(); + if (model_alias.empty()) { + return models; + } + + auto removed = std::remove_if(models.begin(), models.end(), + [&](const ModelInfo& m) { return m.alias != model_alias; }); + models.erase(removed, models.end()); + + return models; + } + private: const IEpDetector& ep_detector_; ILogger& logger_; diff --git a/sdk_v2/cpp/test/internal_api/c_api_test.cc b/sdk_v2/cpp/test/internal_api/c_api_test.cc index a8f072410..ea943c8cd 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -296,6 +296,69 @@ TEST(CApiTest, GetModelsFromCatalog) { api->Manager_Release(mgr); } +TEST(CApiTest, GetModelVersionsNullCatalogFails) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flCatalogApi* catalog_api = api->GetCatalogApi(); + + flModelList* models = nullptr; + flStatus* status = catalog_api->GetModelVersions(nullptr, "alias", nullptr, &models); + ASSERT_NE(status, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(status), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(status); +} + +TEST(CApiTest, GetModelVersionsNullOutputFails) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flCatalogApi* catalog_api = api->GetCatalogApi(); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + flCatalog* cat = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalog(mgr, &cat)); + + flStatus* status = catalog_api->GetModelVersions(cat, "alias", nullptr, nullptr); + ASSERT_NE(status, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(status), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(status); + + api->GetConfigurationApi()->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, GetModelVersionsUnknownAliasReturnsEmptyList) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flCatalogApi* catalog_api = api->GetCatalogApi(); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + + flManager* mgr = nullptr; + flStatus* status = api->Manager_Create(config, &mgr); + if (!IsOk(status)) { + // Manager creation may fail if catalog is unreachable — skip gracefully. + api->Status_Release(status); + api->GetConfigurationApi()->Configuration_Release(config); + GTEST_SKIP() << "Manager creation failed (catalog may be unreachable)"; + } + flCatalog* cat = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalog(mgr, &cat)); + + flModelList* models = nullptr; + ASSERT_FL_OK(api, catalog_api->GetModelVersions(cat, "definitely-not-a-real-alias", nullptr, &models)); + ASSERT_NE(models, nullptr); + EXPECT_EQ(api->ModelList_Size(models), 0u); + api->ModelList_Release(models); + + api->GetConfigurationApi()->Configuration_Release(config); + api->Manager_Release(mgr); +} + // ======================================================================== // ModelList API // ======================================================================== diff --git a/sdk_v2/cpp/test/internal_api/web_service_test_helpers.h b/sdk_v2/cpp/test/internal_api/web_service_test_helpers.h index ade2bd529..99040489c 100644 --- a/sdk_v2/cpp/test/internal_api/web_service_test_helpers.h +++ b/sdk_v2/cpp/test/internal_api/web_service_test_helpers.h @@ -80,6 +80,22 @@ class MockCatalog : public ICatalog { return result; } + std::vector GetModelVersions(const std::string& model_alias, + const std::string& variant_name) override { + std::vector result; + for (auto& m : models_) { + if (!model_alias.empty() && m.Alias() != model_alias) { + continue; + } + for (auto* v : m.Variants()) { + if (variant_name.empty() || v->Info().name == variant_name) { + result.push_back(v); + } + } + } + return result; + } + /// Add a model variant. Groups variants by alias into container models, /// matching BaseModelCatalog behavior. void AddModel(Model model) {