From 29a1bad12d56f055ee2c5e20109933bff8ab6b9d Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:53:05 -0700 Subject: [PATCH 1/5] the implementation for BYOM --- sdk_v2/cpp/CMakeLists.txt | 1 + .../include/foundry_local/foundry_local_c.h | 45 +- .../include/foundry_local/foundry_local_cpp.h | 46 +- .../foundry_local/foundry_local_cpp.inline.h | 98 +++- sdk_v2/cpp/src/c_api.cc | 246 ++++++++- sdk_v2/cpp/src/catalog.h | 21 + sdk_v2/cpp/src/catalog/azure_model_catalog.cc | 9 +- sdk_v2/cpp/src/catalog/base_model_catalog.cc | 89 +++- sdk_v2/cpp/src/catalog/base_model_catalog.h | 17 +- sdk_v2/cpp/src/catalog/catalog_client.cc | 24 +- sdk_v2/cpp/src/catalog/catalog_client.h | 4 +- sdk_v2/cpp/src/catalog/local_model_catalog.cc | 488 ++++++++++++++++++ sdk_v2/cpp/src/catalog/local_model_catalog.h | 48 ++ sdk_v2/cpp/src/inferencing/session/session.cc | 2 +- sdk_v2/cpp/src/manager.cc | 53 +- sdk_v2/cpp/src/manager.h | 10 +- sdk_v2/cpp/src/model.cc | 151 +++++- sdk_v2/cpp/src/model.h | 27 + sdk_v2/cpp/src/model_info.cc | 127 ++++- sdk_v2/cpp/src/model_info.h | 11 + sdk_v2/cpp/test/CMakeLists.txt | 1 + .../test/internal_api/azure_catalog_test.cc | 19 +- .../internal_api/local_model_catalog_test.cc | 150 ++++++ .../cpp/test/internal_api/model_info_test.cc | 18 + 24 files changed, 1594 insertions(+), 111 deletions(-) create mode 100644 sdk_v2/cpp/src/catalog/local_model_catalog.cc create mode 100644 sdk_v2/cpp/src/catalog/local_model_catalog.h create mode 100644 sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index fd1c86b51..b54c2dce5 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -141,6 +141,7 @@ set(FOUNDRY_LOCAL_SOURCES src/catalog/azure_catalog_models.cc src/catalog/catalog_cache.cc src/catalog/catalog_client.cc + src/catalog/local_model_catalog.cc src/catalog/local_model_scanner.cc src/inferencing/generative/audio/audio_generator.cc src/inferencing/generative/audio/audio_session.cc 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 4e9255e4a..2cd7fd1ed 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -60,7 +60,7 @@ * Incremented with each release. * Used to request the API function table via FoundryLocalGetApi. * ----------------------------------------------------------------------- */ -#define FOUNDRY_LOCAL_API_VERSION 1 +#define FOUNDRY_LOCAL_API_VERSION 2 /* ----------------------------------------------------------------------- * Platform export macros (C version) @@ -202,6 +202,12 @@ typedef enum flDeviceType { FOUNDRY_LOCAL_DEVICE_NPU = 3 } flDeviceType; +typedef enum flCatalogType { + FOUNDRY_LOCAL_CATALOG_PUBLIC = 0, + FOUNDRY_LOCAL_CATALOG_LOCAL = 1, + FOUNDRY_LOCAL_CATALOG_PRIVATE = 2, +} flCatalogType; + /// Tensor element data types. Values match ONNX TensorProto.DataType. typedef enum flTensorDataType { FOUNDRY_LOCAL_TENSOR_UNDEFINED = 0, @@ -256,6 +262,16 @@ typedef enum flTensorDataType { #define FOUNDRY_LOCAL_MODEL_PROP_TOOL_CALL_END_STR "tool_call_end" ///< optional tool call end marker token #define FOUNDRY_LOCAL_MODEL_PROP_REASONING_START_STR "reasoning_start" ///< optional reasoning/think start marker token #define FOUNDRY_LOCAL_MODEL_PROP_REASONING_END_STR "reasoning_end" ///< optional reasoning/think end marker token +#define FOUNDRY_LOCAL_MODEL_PROP_DEVICE_TYPE_STR "device_type" ///< CPU, GPU, or NPU +#define FOUNDRY_LOCAL_MODEL_PROP_EP_STR "execution_provider" ///< optional execution provider +#define FOUNDRY_LOCAL_MODEL_PROP_ENTITY_TYPE_STR "entity_type" ///< fixed to "Model" for BYOM +#define FOUNDRY_LOCAL_MODEL_PROP_AUTHOR_STR "author" ///< optional +#define FOUNDRY_LOCAL_MODEL_PROP_QUANTIZATION_STR "quantization" ///< optional +#define FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR "creation_time" ///< ISO-8601 UTC timestamp + +/* flModelInfo registration properties */ +#define FOUNDRY_LOCAL_REG_MODEL_PATH "model_path" +#define FOUNDRY_LOCAL_REG_ALIAS "alias" /* flModelInfo Int properties. Comments provide details on the type and expected values. */ #define FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_TOOL_CALLING_INT "supports_tool_calling" ///< optional bool (not set or -1=unknown, 0=false, 1=true) @@ -265,6 +281,9 @@ typedef enum flTensorDataType { #define FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT "created_at_unix" ///< Unix timestamp. default=0 #define FOUNDRY_LOCAL_MODEL_PROP_IS_TEST_MODEL_INT "is_test_model" ///< bool (0=false, 1=true) #define FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT "context_length" ///< optional int64_t +#define FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT "version" ///< optional non-negative integer +#define FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT "file_size_bytes" ///< optional int64_t +#define FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_HYBRID_REASONING_INT "supports_hybrid_reasoning" ///< optional bool #define FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR "input_modalities" ///< optional, comma-separated #define FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR "output_modalities" ///< optional, comma-separated @@ -706,6 +725,12 @@ typedef struct flApi { bool FL_API_T(Manager_IsShutdownRequested, _In_ const flManager* manager); // End V1 + FL_API_STATUS(Manager_GetCatalogByType, _In_ const flManager* manager, flCatalogType catalog_type, + _Outptr_ flCatalog** out_catalog); + FL_API_STATUS(Manager_GetCatalogByName, _In_ const flManager* manager, _In_ const char* catalog_name, + _Outptr_ flCatalog** out_catalog); + + // End V2 /* Append new function pointers at the end for future versions and add marker for the end of each version */ } flApi; @@ -969,6 +994,15 @@ struct flCatalogApi { _In_opt_ const char* model_name, int32_t max_versions, _Outptr_ flModelList** out_models); // End V1 + /// Register a model in a local catalog. The input ModelInfo is copied. + FL_API_STATUS(RegisterModel, _In_ flCatalog* catalog, _In_ const flModelInfo* model_info, + _Outptr_ flModel** out_model); + /// Unregister by alias or model ID without deleting model assets. + FL_API_STATUS(UnregisterModel, _In_ flCatalog* catalog, _In_ const char* alias_or_model_id); + /// List models explicitly registered in this local catalog. + FL_API_STATUS(GetLocalModels, _In_ const flCatalog* catalog, _Outptr_ flModelList** out_models); + + // End V2 }; /* --- Model API --------------------------------------------------------- */ @@ -1038,6 +1072,15 @@ struct flModelApi { int64_t FL_API_T(Info_GetIntProperty, _In_ const flModelInfo* info, _In_ const char* key, int64_t default_value); // End V1 + /// Create a caller-owned mutable ModelInfo. Release it with ReleaseModelInfo. + FL_API_STATUS(CreateModelInfo, _Outptr_ flModelInfo** out_info); + void FL_API_T(ReleaseModelInfo, _Frees_ptr_opt_ flModelInfo* info); + FL_API_STATUS(Info_SetStringProperty, _In_ flModelInfo* info, _In_ const char* key, _In_ const char* value); + FL_API_STATUS(Info_SetIntProperty, _In_ flModelInfo* info, _In_ const char* key, int64_t value); + FL_API_STATUS(Info_SerializeToFile, _In_ const flModelInfo* info, _In_ const char* file_path); + FL_API_STATUS(Info_DeserializeFromFile, _In_ const char* file_path, _Outptr_ flModelInfo** out_info); + + // End V2 }; #ifdef __cplusplus 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 fe6281302..05bc82c9c 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -64,6 +64,9 @@ namespace detail { /// Returns nullptr if the library does not support the requested API version. inline const flApi* api() { static const flApi* p = FoundryLocalGetApi(FOUNDRY_LOCAL_API_VERSION); + if (!p) { + throw std::runtime_error("Foundry Local runtime does not support the API version requested by this header"); + } return p; } @@ -295,14 +298,30 @@ struct Runtime { std::optional execution_provider; }; +enum class CatalogType { + Public = FOUNDRY_LOCAL_CATALOG_PUBLIC, + Local = FOUNDRY_LOCAL_CATALOG_LOCAL, + Private = FOUNDRY_LOCAL_CATALOG_PRIVATE, +}; + // =========================================================================== -// ModelInfo — non-owning read-only view +// ModelInfo — owning mutable value or non-owning read-only view // =========================================================================== -/// Non-owning view over an opaque flModelInfo. Lifetime is tied to the owning Model/Catalog. Immutable. +/// Opaque model metadata. Default construction creates an owning mutable value for registration. +/// Construction from `const flModelInfo&` creates a non-owning read-only view tied to its Model/Catalog. class ModelInfo { public: - explicit ModelInfo(const flModelInfo& info) noexcept : info_(&info) {} + ModelInfo(); + explicit ModelInfo(const flModelInfo& info) noexcept : handle_(&info) {} + + ModelInfo(ModelInfo&&) noexcept = default; + ModelInfo& operator=(ModelInfo&&) noexcept = default; + + ModelInfo& SetStringProperty(const char* key, const char* value); + ModelInfo& SetIntProperty(const char* key, int64_t value); + void SerializeToFile(const std::string& file_path) const; + static ModelInfo DeserializeFromFile(const std::string& file_path); // Core identity. std::string_view Id() const noexcept; @@ -372,8 +391,11 @@ class ModelInfo { std::optional Capabilities() const noexcept; private: + explicit ModelInfo(flModelInfo& info); static std::string_view safe(const char* s) noexcept { return s ? s : ""; } - const flModelInfo* info_; + detail::Base handle_; + + friend class Catalog; }; // =========================================================================== @@ -777,6 +799,15 @@ class ICatalog { virtual ModelList GetModelVersions(const std::string& model_alias, const std::string& variant_name = {}, int max_versions = 50) = 0; + virtual std::unique_ptr RegisterModel(const ModelInfo&) { + throw Error("models can only be registered in a local catalog", FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + } + virtual void UnregisterModel(const std::string&) { + throw Error("models can only be unregistered from a local catalog", FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + } + virtual ModelList GetLocalModels() const { + throw Error("local model listing is unsupported by this catalog", FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + } }; // =========================================================================== @@ -802,6 +833,9 @@ class Catalog final : public ICatalog { ModelList GetModelVersions(const std::string& model_alias, const std::string& variant_name = {}, int max_versions = 50) override; + std::unique_ptr RegisterModel(const ModelInfo& model_info) override; + void UnregisterModel(const std::string& alias_or_model_id) override; + ModelList GetLocalModels() const override; private: detail::Base handle_; @@ -831,6 +865,8 @@ class Manager { /// Get the catalog for querying models. Creates on first call, caches internally. ICatalog& GetCatalog() const; + ICatalog& GetCatalog(CatalogType type) const; + ICatalog& GetCatalog(const std::string& catalog_name) const; /// Start the embedded web service. void StartWebService(); @@ -866,7 +902,9 @@ class Manager { detail::Base handle_; Configuration config_; mutable std::unique_ptr catalog_; + mutable std::unique_ptr local_catalog_; mutable std::unique_ptr catalog_once_{std::make_unique()}; + mutable std::unique_ptr local_catalog_once_{std::make_unique()}; }; // =========================================================================== 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 08c8f594f..c0a95f469 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 @@ -203,6 +203,34 @@ inline ICatalog& Manager::GetCatalog() const { return *catalog_; } +inline ICatalog& Manager::GetCatalog(CatalogType type) const { + if (type == CatalogType::Public) { + return GetCatalog(); + } + + if (type != CatalogType::Local) { + flCatalog* ignored = nullptr; + Check(detail::api()->Manager_GetCatalogByType(handle_.get(), static_cast(type), &ignored)); + } + + std::call_once(*local_catalog_once_, [this, type]() { + flCatalog* catalog = nullptr; + Check(detail::api()->Manager_GetCatalogByType(handle_.get(), static_cast(type), &catalog)); + local_catalog_ = std::make_unique(*catalog); + }); + return *local_catalog_; +} + +inline ICatalog& Manager::GetCatalog(const std::string& catalog_name) const { + if (catalog_name == "local") { + return GetCatalog(CatalogType::Local); + } + + flCatalog* catalog = nullptr; + Check(detail::api()->Manager_GetCatalogByName(handle_.get(), catalog_name.c_str(), &catalog)); + return GetCatalog(); +} + inline void Manager::StartWebService() { Check(detail::api()->Manager_WebServiceStart(handle_.get_mutable())); } @@ -289,32 +317,62 @@ inline flManager* detail::CreateManager(const Configuration& config) { // ModelInfo // =========================================================================== +inline ModelInfo::ModelInfo() + : handle_([] { + flModelInfo* info = nullptr; + Check(detail::model_api()->CreateModelInfo(&info)); + return info; + }(), detail::model_api()->ReleaseModelInfo) {} + +inline ModelInfo::ModelInfo(flModelInfo& info) + : handle_(&info, detail::model_api()->ReleaseModelInfo) {} + +inline ModelInfo& ModelInfo::SetStringProperty(const char* key, const char* value) { + Check(detail::model_api()->Info_SetStringProperty(handle_.get_mutable(), key, value)); + return *this; +} + +inline ModelInfo& ModelInfo::SetIntProperty(const char* key, int64_t value) { + Check(detail::model_api()->Info_SetIntProperty(handle_.get_mutable(), key, value)); + return *this; +} + +inline void ModelInfo::SerializeToFile(const std::string& file_path) const { + Check(detail::model_api()->Info_SerializeToFile(handle_.get(), file_path.c_str())); +} + +inline ModelInfo ModelInfo::DeserializeFromFile(const std::string& file_path) { + flModelInfo* info = nullptr; + Check(detail::model_api()->Info_DeserializeFromFile(file_path.c_str(), &info)); + return ModelInfo(*info); +} + inline std::string_view ModelInfo::Id() const noexcept { - return safe(detail::model_api()->Info_GetId(info_)); + return safe(detail::model_api()->Info_GetId(handle_.get())); } inline std::string_view ModelInfo::Name() const noexcept { - return safe(detail::model_api()->Info_GetName(info_)); + return safe(detail::model_api()->Info_GetName(handle_.get())); } inline int ModelInfo::Version() const noexcept { - return detail::model_api()->Info_GetVersion(info_); + return detail::model_api()->Info_GetVersion(handle_.get()); } inline std::string_view ModelInfo::Alias() const noexcept { - return safe(detail::model_api()->Info_GetAlias(info_)); + return safe(detail::model_api()->Info_GetAlias(handle_.get())); } inline std::string_view ModelInfo::Uri() const noexcept { - return safe(detail::model_api()->Info_GetUri(info_)); + return safe(detail::model_api()->Info_GetUri(handle_.get())); } inline flDeviceType ModelInfo::DeviceType() const noexcept { - return detail::model_api()->Info_GetDeviceType(info_); + return detail::model_api()->Info_GetDeviceType(handle_.get()); } inline std::optional ModelInfo::ExecutionProvider() const noexcept { - const char* v = detail::model_api()->Info_GetExecutionProvider(info_); + const char* v = detail::model_api()->Info_GetExecutionProvider(handle_.get()); return v ? std::optional{v} : std::nullopt; } @@ -327,7 +385,7 @@ inline std::optional ModelInfo::GetRuntime() const noexcept { } inline std::optional ModelInfo::GetPromptTemplate(const char* key) const noexcept { - const flKeyValuePairs* kvps = detail::model_api()->Info_GetPromptTemplates(info_); + const flKeyValuePairs* kvps = detail::model_api()->Info_GetPromptTemplates(handle_.get()); if (!kvps) { return std::nullopt; } @@ -336,7 +394,7 @@ inline std::optional ModelInfo::GetPromptTemplate(const char* } inline std::optional ModelInfo::GetModelSetting(const char* key) const noexcept { - const flKeyValuePairs* kvps = detail::model_api()->Info_GetModelSettings(info_); + const flKeyValuePairs* kvps = detail::model_api()->Info_GetModelSettings(handle_.get()); if (!kvps) { return std::nullopt; } @@ -345,7 +403,7 @@ inline std::optional ModelInfo::GetModelSetting(const char* ke } inline std::optional ModelInfo::GetModelSettings() const noexcept { - const flKeyValuePairs* kvps = detail::model_api()->Info_GetModelSettings(info_); + const flKeyValuePairs* kvps = detail::model_api()->Info_GetModelSettings(handle_.get()); if (!kvps) { return std::nullopt; } @@ -353,12 +411,12 @@ inline std::optional ModelInfo::GetModelSettings() const noexcept } inline std::optional ModelInfo::GetStringProperty(const char* key) const noexcept { - const char* v = detail::model_api()->Info_GetStringProperty(info_, key); + const char* v = detail::model_api()->Info_GetStringProperty(handle_.get(), key); return v ? std::optional{v} : std::nullopt; } inline int64_t ModelInfo::GetIntProperty(const char* key, int64_t default_value) const noexcept { - return detail::model_api()->Info_GetIntProperty(info_, key, default_value); + return detail::model_api()->Info_GetIntProperty(handle_.get(), key, default_value); } // --- Typed property accessors --- @@ -624,6 +682,22 @@ inline ModelList Catalog::GetModelVersions(const std::string& model_alias, return ModelList(*models); } +inline std::unique_ptr Catalog::RegisterModel(const ModelInfo& model_info) { + flModel* model = nullptr; + Check(detail::catalog_api()->RegisterModel(handle_.get_mutable(), model_info.handle_.get(), &model)); + return std::make_unique(*model); +} + +inline void Catalog::UnregisterModel(const std::string& alias_or_model_id) { + Check(detail::catalog_api()->UnregisterModel(handle_.get_mutable(), alias_or_model_id.c_str())); +} + +inline ModelList Catalog::GetLocalModels() const { + flModelList* models = nullptr; + Check(detail::catalog_api()->GetLocalModels(handle_.get(), &models)); + return ModelList(*models); +} + // =========================================================================== // Item // =========================================================================== diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index dbf49cc03..543d46ecc 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -71,7 +71,8 @@ struct flCatalog { // --- Manager --- struct flManager { fl::Manager& impl; - std::unique_ptr catalog; // stores the flCatalog wrapper around impl.GetCatalog() + std::unique_ptr public_catalog; + std::unique_ptr local_catalog; mutable std::vector urls_cache; }; @@ -327,8 +328,9 @@ FL_API_STATUS_IMPL(Manager_CreateImpl, const flConfiguration* config, flManager* } auto& mgr = fl::Manager::Create(*cfg); - auto wrapper = std::make_unique(flManager{mgr, nullptr, {}}); - wrapper->catalog = std::make_unique(flCatalog{mgr.GetCatalog()}); + auto wrapper = std::make_unique(flManager{mgr, nullptr, nullptr, {}}); + wrapper->public_catalog = std::make_unique(flCatalog{mgr.GetCatalog(fl::CatalogType::kPublic)}); + wrapper->local_catalog = std::make_unique(flCatalog{mgr.GetCatalog(fl::CatalogType::kLocal)}); *out_manager = wrapper.release(); return nullptr; API_IMPL_END @@ -349,7 +351,43 @@ FL_API_STATUS_IMPL(Manager_GetCatalogImpl, const flManager* manager, flCatalog** return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); } - *out_catalog = manager->catalog.get(); + *out_catalog = manager->public_catalog.get(); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Manager_GetCatalogByTypeImpl, const flManager* manager, flCatalogType catalog_type, + flCatalog** out_catalog) { + API_IMPL_BEGIN + if (!manager || !out_catalog) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + switch (catalog_type) { + case FOUNDRY_LOCAL_CATALOG_PUBLIC: + *out_catalog = manager->public_catalog.get(); + return nullptr; + case FOUNDRY_LOCAL_CATALOG_LOCAL: + *out_catalog = manager->local_catalog.get(); + return nullptr; + case FOUNDRY_LOCAL_CATALOG_PRIVATE: + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "no private catalog has been configured"); + default: + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "unknown catalog type"); + } + API_IMPL_END +} + +FL_API_STATUS_IMPL(Manager_GetCatalogByNameImpl, const flManager* manager, const char* catalog_name, + flCatalog** out_catalog) { + API_IMPL_BEGIN + if (!manager || !catalog_name || !out_catalog) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + auto& catalog = manager->impl.GetCatalog(catalog_name); + *out_catalog = catalog.GetType() == fl::CatalogType::kLocal ? manager->local_catalog.get() + : manager->public_catalog.get(); return nullptr; API_IMPL_END } @@ -715,6 +753,57 @@ FL_API_STATUS_IMPL(Catalog_GetModelVersionsImpl, const flCatalog* catalog, API_IMPL_END } +FL_API_STATUS_IMPL(Catalog_RegisterModelImpl, flCatalog* catalog, const flModelInfo* model_info, + flModel** out_model) { + API_IMPL_BEGIN + if (!catalog || !model_info || !out_model) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + *out_model = AsHandle(catalog->impl.RegisterModel(*AsImpl(model_info))); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Catalog_UnregisterModelImpl, flCatalog* catalog, const char* alias_or_model_id) { + API_IMPL_BEGIN + if (!catalog || !alias_or_model_id) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + catalog->impl.UnregisterModel(alias_or_model_id); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Catalog_GetLocalModelsImpl, const flCatalog* catalog, flModelList** out_models) { + API_IMPL_BEGIN + if (!catalog || !out_models) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + auto models = catalog->impl.GetLocalModels(); + auto list = std::make_unique(); + list->items.reserve(models.size()); + for (auto* model : models) { + list->items.push_back(AsHandle(model)); + } + *out_models = list.release(); + return nullptr; + API_IMPL_END +} + +static const flCatalogApi g_catalog_api_v1 = { + Catalog_GetNameImpl, + Catalog_GetModelsImpl, + Catalog_GetModelImpl, + Catalog_GetModelVariantImpl, + Catalog_GetLatestVersionImpl, + Catalog_GetCachedModelsImpl, + Catalog_GetLoadedModelsImpl, + Catalog_GetModelVersionsImpl, +}; + static const flCatalogApi g_catalog_api = { Catalog_GetNameImpl, Catalog_GetModelsImpl, @@ -724,6 +813,9 @@ static const flCatalogApi g_catalog_api = { Catalog_GetCachedModelsImpl, Catalog_GetLoadedModelsImpl, Catalog_GetModelVersionsImpl, + Catalog_RegisterModelImpl, + Catalog_UnregisterModelImpl, + Catalog_GetLocalModelsImpl, }; // ======================================================================== @@ -838,15 +930,6 @@ FL_API_STATUS_IMPL(Model_RemoveFromCacheImpl, flModel* model) { } auto* impl = AsImpl(model); - if (!impl->IsCached()) { - return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is not cached locally"); - } - - if (impl->IsLoaded()) { - return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, - "cannot remove a loaded model from cache; unload it first"); - } - impl->RemoveFromCache(); return nullptr; API_IMPL_END @@ -970,6 +1053,86 @@ static int64_t FL_API_CALL Info_GetIntPropertyImpl(const flModelInfo* info, return AsImpl(info)->GetPropertyWithDefault(key, default_value); } +FL_API_STATUS_IMPL(ModelInfo_CreateImpl, flModelInfo** out_info) { + API_IMPL_BEGIN + if (!out_info) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "out_info must not be null"); + } + *out_info = AsHandle(new fl::ModelInfo()); + return nullptr; + API_IMPL_END +} + +static void FL_API_CALL ModelInfo_ReleaseImpl(flModelInfo* info) FL_NO_EXCEPTION { + delete AsImpl(info); +} + +FL_API_STATUS_IMPL(Info_SetStringPropertyImpl, flModelInfo* info, const char* key, const char* value) { + API_IMPL_BEGIN + if (!info || !key || !value) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + fl::SetModelInfoStringProperty(*AsImpl(info), key, value); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Info_SetIntPropertyImpl, flModelInfo* info, const char* key, int64_t value) { + API_IMPL_BEGIN + if (!info || !key) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + fl::SetModelInfoIntProperty(*AsImpl(info), key, value); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Info_SerializeToFileImpl, const flModelInfo* info, const char* file_path) { + API_IMPL_BEGIN + if (!info || !file_path) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + fl::SerializeModelInfoToFile(*AsImpl(info), file_path); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Info_DeserializeFromFileImpl, const char* file_path, flModelInfo** out_info) { + API_IMPL_BEGIN + if (!file_path || !out_info) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + *out_info = AsHandle(new fl::ModelInfo(fl::DeserializeModelInfoFromFile(file_path))); + return nullptr; + API_IMPL_END +} + +static const flModelApi g_model_api_v1 = { + Model_GetInfoImpl, + Model_GetInputOutputInfoImpl, + Model_IsCachedImpl, + Model_GetPathImpl, + Model_DownloadImpl, + Model_IsLoadedImpl, + Model_LoadImpl, + Model_UnloadImpl, + Model_RemoveFromCacheImpl, + Model_GetVariantsImpl, + Model_SelectVariantImpl, + Info_GetIdImpl, + Info_GetNameImpl, + Info_GetVersionImpl, + Info_GetAliasImpl, + Info_GetUriImpl, + Info_GetDeviceTypeImpl, + Info_GetExecutionProviderImpl, + Info_GetTaskImpl, + Info_GetPromptTemplatesImpl, + Info_GetModelSettingsImpl, + Info_GetStringPropertyImpl, + Info_GetIntPropertyImpl, +}; + static const flModelApi g_model_api = { Model_GetInfoImpl, Model_GetInputOutputInfoImpl, @@ -994,6 +1157,12 @@ static const flModelApi g_model_api = { Info_GetModelSettingsImpl, Info_GetStringPropertyImpl, Info_GetIntPropertyImpl, + ModelInfo_CreateImpl, + ModelInfo_ReleaseImpl, + Info_SetStringPropertyImpl, + Info_SetIntPropertyImpl, + Info_SerializeToFileImpl, + Info_DeserializeFromFileImpl, }; // ======================================================================== @@ -1827,6 +1996,10 @@ static const flCatalogApi* FL_API_CALL GetCatalogApiImpl() FL_NO_EXCEPTION { return &g_catalog_api; } +static const flCatalogApi* FL_API_CALL GetCatalogApiV1Impl() FL_NO_EXCEPTION { + return &g_catalog_api_v1; +} + static const flConfigurationApi* FL_API_CALL GetConfigurationApiImpl() FL_NO_EXCEPTION { return &g_configuration_api; } @@ -1843,6 +2016,10 @@ static const flModelApi* FL_API_CALL GetModelApiImpl() FL_NO_EXCEPTION { return &g_model_api; } +static const flModelApi* FL_API_CALL GetModelApiV1Impl() FL_NO_EXCEPTION { + return &g_model_api_v1; +} + // ======================================================================== // Root API function table (version 1) // ======================================================================== @@ -1863,11 +2040,11 @@ static const flApi g_api_v1 = { Manager_WebServiceStopImpl, /* Sub-API accessors */ - GetCatalogApiImpl, + GetCatalogApiV1Impl, GetConfigurationApiImpl, GetItemApiImpl, GetInferenceApiImpl, - GetModelApiImpl, + GetModelApiV1Impl, /* KeyValuePairs */ CreateKeyValuePairsImpl, @@ -1888,6 +2065,40 @@ static const flApi g_api_v1 = { Manager_IsEpDownloadInProgressImpl, Manager_ShutdownImpl, Manager_IsShutdownRequestedImpl, + }; + + static const flApi g_api_v2 = { + Status_CreateImpl, + Status_ReleaseImpl, + Status_GetErrorCodeImpl, + Status_GetErrorMessageImpl, + Manager_CreateImpl, + Manager_ReleaseImpl, + Manager_GetCatalogImpl, + Manager_WebServiceStartImpl, + Manager_WebServiceUrlsImpl, + Manager_WebServiceStopImpl, + GetCatalogApiImpl, + GetConfigurationApiImpl, + GetItemApiImpl, + GetInferenceApiImpl, + GetModelApiImpl, + CreateKeyValuePairsImpl, + AddKeyValuePairImpl, + GetKeyValueImpl, + GetKeyValuePairsImpl, + RemoveKeyValuePairImpl, + KeyValuePairs_ReleaseImpl, + ModelList_ReleaseImpl, + ModelList_SizeImpl, + ModelList_GetAtImpl, + Manager_GetDiscoverableEpsImpl, + Manager_DownloadAndRegisterEpsImpl, + Manager_IsEpDownloadInProgressImpl, + Manager_ShutdownImpl, + Manager_IsShutdownRequestedImpl, + Manager_GetCatalogByTypeImpl, + Manager_GetCatalogByNameImpl, }; // ======================================================================== @@ -1897,9 +2108,12 @@ static const flApi g_api_v1 = { extern "C" { FL_EXPORT const flApi* FL_API_CALL FoundryLocalGetApi(uint32_t version) FL_NO_EXCEPTION { - if (version == 0 || version <= FOUNDRY_LOCAL_API_VERSION) { + if (version == 1) { return &g_api_v1; } + if (version == 0 || version == 2) { + return &g_api_v2; + } return nullptr; } diff --git a/sdk_v2/cpp/src/catalog.h b/sdk_v2/cpp/src/catalog.h index 71aedbc1d..3b54c5b94 100644 --- a/sdk_v2/cpp/src/catalog.h +++ b/sdk_v2/cpp/src/catalog.h @@ -2,13 +2,22 @@ // Licensed under the MIT License. #pragma once +#include "exception.h" #include "model.h" +#include + #include #include namespace fl { +enum class CatalogType { + kPublic, + kLocal, + kPrivate, +}; + /// Abstract catalog interface for querying available models. /// Mirrors the C API's flCatalogApi surface. class ICatalog { @@ -19,6 +28,8 @@ class ICatalog { /// For Azure catalogs this is the catalog URI. virtual const std::string& GetName() const = 0; + virtual CatalogType GetType() const { return CatalogType::kPublic; } + /// Lists all models in the catalog. virtual std::vector ListModels() const = 0; @@ -57,6 +68,16 @@ class ICatalog { /// Lists only models that are currently loaded into a runtime. virtual std::vector GetLoadedModels() const = 0; + virtual Model* RegisterModel(const ModelInfo& /*model_info*/) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "models can only be registered in a local catalog"); + } + + virtual void UnregisterModel(const std::string& /*alias_or_model_id*/) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "models can only be unregistered from a local catalog"); + } + + virtual std::vector GetLocalModels() const { return {}; } + /// Invalidate the cached model list so the next query re-fetches. /// Called after EP registration changes, since the available device filters /// may now include additional execution providers. diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc index 39afcae37..d67f957a2 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc @@ -45,9 +45,7 @@ AzureModelCatalog::AzureModelCatalog(std::vector AzureModelCatalog::FetchModels() const { - // In cache-only mode, read only from the disk cache file — no network calls, no local model scanning. - // The cache file already includes local models from the last full catalog refresh by the long-running service - // process. + // In cache-only mode, read only from the disk cache file — no network calls or model scanning. // TODO: For our CLI usage the catalog file would be current as we use an ephemeral port for the web service and // therefore have to run FL first to acquire the external URL value, and that run would have updated the cached // catalog info. @@ -63,6 +61,11 @@ std::vector AzureModelCatalog::FetchModels() const { if (cached) { for (const auto& info : *cached) { + const auto* provider = info.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR); + if (provider && *provider == "Local") { + // Ignore legacy synthesized BYOM entries. Local models now require explicit local-catalog registration. + continue; + } models.push_back(model_factory_(ModelInfo(info), /*local_path=*/"")); } } diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.cc b/sdk_v2/cpp/src/catalog/base_model_catalog.cc index 3c96eb51c..3e688688a 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.cc @@ -16,7 +16,9 @@ namespace fl { BaseModelCatalog::BaseModelCatalog(std::string name, ILogger& logger) - : name_(std::move(name)), logger_(logger) {} + : BaseModelCatalog(std::move(name), CatalogType::kPublic, logger) {} +BaseModelCatalog::BaseModelCatalog(std::string name, CatalogType type, ILogger& logger) + : name_(std::move(name)), type_(type), logger_(logger) {} BaseModelCatalog::~BaseModelCatalog() = default; void BaseModelCatalog::PopulateModels(std::vector variants) const { @@ -52,14 +54,16 @@ void BaseModelCatalog::PopulateModels(std::vector variants) const { if (populated_) { // Build a set of existing aliases for fast lookup. std::unordered_map existing_aliases; - for (auto& m : models_) { - existing_aliases[m->Alias()] = m.get(); + for (auto& stored : models_) { + if (stored.active) { + existing_aliases[stored.model->Alias()] = stored.model.get(); + } } size_t new_count = 0; for (auto& [alias, model] : alias_to_model) { if (!existing_aliases.contains(alias)) { - models_.push_back(std::make_unique(std::move(model))); + models_.push_back({std::make_unique(std::move(model)), true}); ++new_count; } } @@ -77,7 +81,7 @@ void BaseModelCatalog::PopulateModels(std::vector variants) const { // Initial population: move all models into stable storage. models_.reserve(alias_to_model.size()); for (auto& [alias, model] : alias_to_model) { - models_.push_back(std::make_unique(std::move(model))); + models_.push_back({std::make_unique(std::move(model)), true}); } logger_.Log(LogLevel::Debug, @@ -98,15 +102,20 @@ void BaseModelCatalog::IntegrateVariants(std::vector variants) const { // 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(); + for (auto& stored : models_) { + if (stored.active) { + alias_to_existing[stored.model->Alias()] = stored.model.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()) { + for (auto& stored : models_) { + if (!stored.active) { + continue; + } + for (auto* v : stored.model->Variants()) { existing_ids.insert(v->Info().model_id); } } @@ -151,7 +160,7 @@ void BaseModelCatalog::IntegrateVariants(std::vector variants) const { container.SelectDefaultVariant(); - models_.push_back(std::make_unique(std::move(container))); + models_.push_back({std::make_unique(std::move(container)), true}); ++added_aliases; added_variants += alias_variants.size(); } @@ -169,7 +178,12 @@ void BaseModelCatalog::IntegrateVariants(std::vector variants) const { void BaseModelCatalog::RebuildIndex() const { auto new_index = std::make_shared(); - for (auto& m : models_) { + for (auto& stored : models_) { + if (!stored.active) { + continue; + } + + auto& m = stored.model; new_index->alias_index[m->Alias()] = m.get(); for (auto* variant : m->Variants()) { @@ -249,8 +263,10 @@ std::vector BaseModelCatalog::ListModels() const { std::lock_guard lock(mutex_); std::vector result; result.reserve(models_.size()); - for (auto& m : models_) { - result.push_back(m.get()); + for (auto& stored : models_) { + if (stored.active) { + result.push_back(stored.model.get()); + } } return result; @@ -346,9 +362,9 @@ std::vector BaseModelCatalog::GetCachedModels() const { std::lock_guard lock(mutex_); std::vector result; - for (auto& m : models_) { - if (m->IsCached()) { - result.push_back(m.get()); + for (auto& stored : models_) { + if (stored.active && stored.model->IsCached()) { + result.push_back(stored.model.get()); } } @@ -360,15 +376,50 @@ std::vector BaseModelCatalog::GetLoadedModels() const { std::lock_guard lock(mutex_); std::vector result; - for (auto& m : models_) { - if (m->IsLoaded()) { - result.push_back(m.get()); + for (auto& stored : models_) { + if (stored.active && stored.model->IsLoaded()) { + result.push_back(stored.model.get()); } } return result; } +Model* BaseModelCatalog::AddModel(Model model) { + EnsurePopulated(); + std::lock_guard lock(mutex_); + auto container = std::make_unique(Model::MakeContainer(std::move(model))); + container->SelectDefaultVariant(); + auto* result = container.get(); + models_.push_back({std::move(container), true}); + RebuildIndex(); + return result; +} + +bool BaseModelCatalog::DeactivateModel(const std::string& alias_or_model_id) { + EnsurePopulated(); + std::lock_guard lock(mutex_); + for (auto& stored : models_) { + if (!stored.active) { + continue; + } + + bool matches = stored.model->Alias() == alias_or_model_id; + for (auto* variant : stored.model->Variants()) { + matches = matches || variant->Id() == alias_or_model_id; + } + + if (matches) { + stored.active = false; + stored.model->Deactivate(); + RebuildIndex(); + return true; + } + } + + return false; +} + std::vector BaseModelCatalog::GetModelVersions(const std::string& model_alias, const std::string& variant_name, int max_versions) { diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.h b/sdk_v2/cpp/src/catalog/base_model_catalog.h index 97411e395..c72857e50 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.h @@ -33,6 +33,7 @@ class BaseModelCatalog : public ICatalog { ~BaseModelCatalog() override; const std::string& GetName() const override { return name_; } + CatalogType GetType() const override { return type_; } // ICatalog implementations — query/lookup layer std::vector ListModels() const override; @@ -47,6 +48,11 @@ class BaseModelCatalog : public ICatalog { void InvalidateCache() override; protected: + BaseModelCatalog(std::string name, CatalogType type, ILogger& logger); + + Model* AddModel(Model model); + bool DeactivateModel(const std::string& alias_or_model_id); + /// Derived classes implement this to fetch model variants from their source. /// Returns the full variant list. Base class handles caching and indexing. /// Maps to C# FetchModelInfoAsync. @@ -82,9 +88,13 @@ class BaseModelCatalog : public ICatalog { std::unordered_map name_index; // name -> latest version Model* }; - /// Stable model storage. unique_ptr ensures addresses never change. - /// Models are only appended, never removed — external Model* pointers remain valid. - mutable std::vector> models_; + struct StoredModel { + std::unique_ptr model; + bool active = true; + }; + + /// Stable append-only storage. Inactive models are tombstones retained for pointer safety. + mutable std::vector models_; /// Lookup indices, rebuilt on each populate/refresh. /// Guarded by std::atomic_load/store free functions so readers get a consistent @@ -127,6 +137,7 @@ class BaseModelCatalog : public ICatalog { mutable std::vector> version_query_models_; std::string name_; + CatalogType type_; ILogger& logger_; }; diff --git a/sdk_v2/cpp/src/catalog/catalog_client.cc b/sdk_v2/cpp/src/catalog/catalog_client.cc index 6f0a51e38..731d8bed4 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.cc +++ b/sdk_v2/cpp/src/catalog/catalog_client.cc @@ -1,9 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #include "catalog/catalog_client.h" -#include "utils.h" - -#include #include @@ -50,25 +47,8 @@ std::vector FetchAllModelInfosWithCachedModels( logger.Log(LogLevel::Warning, "catalog: failed to fetch cached model IDs — unknown error"); } - // Step 4: Create basic entries for any IDs still unresolved (BYO models). - for (const auto& id : unresolved_ids) { - if (resolved_ids.find(id) != resolved_ids.end()) { - continue; - } - - auto [name, version] = Utils::SplitModelNameAndVersion(id); - - ModelInfo info; - info.model_id = id; - info.name = name; - info.alias = name; - info.uri = "local://" + name; - info.version = version; - info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR] = "Local"; - info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR] = "ONNX"; - - result.push_back(std::move(info)); - } + // IDs the public source does not recognize are intentionally omitted. Arbitrary models copied into the + // cache must be explicitly registered in the local catalog instead of appearing in the public catalog. } return result; diff --git a/sdk_v2/cpp/src/catalog/catalog_client.h b/sdk_v2/cpp/src/catalog/catalog_client.h index e3afcfe78..5c5940293 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.h +++ b/sdk_v2/cpp/src/catalog/catalog_client.h @@ -49,8 +49,8 @@ class ICatalogClient { } }; -/// Production helper that combines a catalog fetch with locally cached model -/// resolution and BYO synthesis. +/// Production helper that combines a catalog fetch with resolution of cached versions known to the public source. +/// Unknown cache entries are omitted; BYOM models require explicit local-catalog registration. std::vector FetchAllModelInfosWithCachedModels( ICatalogClient& client, const std::vector& cached_model_ids, diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc new file mode 100644 index 000000000..378356c9a --- /dev/null +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -0,0 +1,488 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "catalog/local_model_catalog.h" + +#include "exception.h" +#include "inferencing/generative/genai_config.h" +#include "util/file_lock.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#endif + +namespace fl { +namespace { + +constexpr const char* kRegistrationIdProperty = "_local_registration_id"; + +std::string UtcTimestamp(int64_t unix_time) { + std::time_t value = static_cast(unix_time); + std::tm utc{}; +#ifdef _WIN32 + gmtime_s(&utc, &value); +#else + gmtime_r(&value, &utc); +#endif + std::ostringstream stream; + stream << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); + return stream.str(); +} + +bool HasParentTraversal(const std::filesystem::path& path) { + for (const auto& component : path) { + if (component == "..") { + return true; + } + } + return false; +} + +int64_t DirectorySize(const std::filesystem::path& path) { + // Best-effort deterministic metadata decoration only. This does not discover registrations or validate assets; + // catalog membership comes exclusively from the flat per-catalog registration index. + std::error_code ec; + if (!std::filesystem::is_directory(path, ec)) { + return 0; + } + + int64_t total = 0; + for (std::filesystem::recursive_directory_iterator it(path, std::filesystem::directory_options::skip_permission_denied, + ec), end; + it != end; it.increment(ec)) { + if (ec) { + ec.clear(); + continue; + } + if (!it->is_regular_file(ec) || it->path().filename() == "model_metadata.yml") { + continue; + } + total += static_cast(it->file_size(ec)); + ec.clear(); + } + return total; +} + +std::string EscapeYaml(std::string_view value) { + std::string result{"\""}; + for (const char ch : value) { + if (ch == '\\' || ch == '"') { + result.push_back('\\'); + } + if (ch == '\n') { + result += "\\n"; + } else if (ch != '\r') { + result.push_back(ch); + } + } + result.push_back('"'); + return result; +} + +void WriteOptionalYamlString(std::ostream& stream, const ModelInfo& info, const char* key, const char* yaml_key) { + const auto* value = info.GetPropertyStr(key); + if (value && !value->empty()) { + stream << yaml_key << ": " << EscapeYaml(*value) << '\n'; + } +} + +void WriteOptionalYamlInt(std::ostream& stream, const ModelInfo& info, const char* key, const char* yaml_key) { + const auto* value = info.GetPropertyInt(key); + if (value) { + stream << yaml_key << ": " << *value << '\n'; + } +} + +nlohmann::json RegistrationToJson(const LocalModelCatalog::Registration& registration) { + return { + {"alias", registration.info.alias}, + {"model_path", registration.model_path}, + {"registered_at", registration.info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, {})}, + {"properties", ModelInfoToPropertyBagJson(registration.info)}, + }; +} + +} // namespace + +LocalModelCatalog::LocalModelCatalog(std::filesystem::path app_data_dir, ModelFactory model_factory, ILogger& logger) + : BaseModelCatalog("local", CatalogType::kLocal, logger), + catalog_dir_(std::move(app_data_dir) / "catalogs" / "local"), + index_path_(catalog_dir_ / "local_models.json"), + lock_path_(catalog_dir_ / "local_models.lock"), + model_factory_(std::move(model_factory)), + logger_(logger) {} + +std::vector LocalModelCatalog::FetchModels() const { + FileLock file_lock(lock_path_); + std::vector models; + for (const auto& registration : LoadRegistrations()) { + models.push_back(CreateModel(registration)); + } + return models; +} + +Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { + const auto* model_path_value = model_info.GetPropertyStr(FOUNDRY_LOCAL_REG_MODEL_PATH); + const auto* alias_value = model_info.GetPropertyStr(FOUNDRY_LOCAL_REG_ALIAS); + if (!model_path_value || model_path_value->empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_path is required"); + } + if (!alias_value || alias_value->empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias is required"); + } + if (!std::regex_match(*alias_value, std::regex("[A-Za-z0-9][A-Za-z0-9._-]*"))) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias must match [a-zA-Z0-9][a-zA-Z0-9._-]*"); + } + + std::filesystem::path supplied_path(*model_path_value); + if (HasParentTraversal(supplied_path)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_path must not contain '..' path components"); + } + + const auto model_path = std::filesystem::absolute(supplied_path).lexically_normal().string(); + ListModels(); + Registration registration; + { + std::lock_guard guard(registration_mutex_); + FileLock file_lock(lock_path_); + auto registrations = LoadRegistrations(); + for (const auto& existing : registrations) { + if (existing.info.alias == *alias_value) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + "a model with alias '" + *alias_value + "' is already registered"); + } + } + + registration = {ResolveMetadata(model_info, model_path, *alias_value), model_path}; + WriteMetadata(registration); + registrations.push_back(registration); + SaveRegistrations(registrations); + } + + try { + return AddModel(CreateModel(registration)); + } catch (...) { + std::lock_guard guard(registration_mutex_); + FileLock file_lock(lock_path_); + auto registrations = LoadRegistrations(); + registrations.erase(std::remove_if(registrations.begin(), registrations.end(), [&](const Registration& entry) { + return entry.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == + registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); + }), + registrations.end()); + SaveRegistrations(registrations); + throw; + } +} + +void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { + if (alias_or_model_id.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias_or_model_id must not be empty"); + } + + auto* model = GetModel(alias_or_model_id); + if (!model) { + model = GetModelVariant(alias_or_model_id); + } + if (!model) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model not found: " + alias_or_model_id); + } + model->BeginUnregister(); + bool unregister_lock_held = true; + try { + if (model->IsLoaded()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "cannot unregister a loaded model; unload it first"); + } + + { + std::lock_guard guard(registration_mutex_); + FileLock file_lock(lock_path_); + auto registrations = LoadRegistrations(); + auto end = std::remove_if(registrations.begin(), registrations.end(), [&](const Registration& registration) { + return registration.info.alias == alias_or_model_id || registration.info.model_id == alias_or_model_id; + }); + if (end == registrations.end()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model not found: " + alias_or_model_id); + } + + registrations.erase(end, registrations.end()); + SaveRegistrations(registrations); + } + + DeactivateModel(alias_or_model_id); + model->CancelUnregister(); + unregister_lock_held = false; + } catch (...) { + if (unregister_lock_held) { + model->CancelUnregister(); + } + throw; + } +} + +std::vector LocalModelCatalog::GetLocalModels() const { + return ListModels(); +} + +ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& supplied, + const std::string& model_path, + const std::string& alias) const { + auto resolved = supplied; + const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + const auto version = resolved.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, int64_t{0}); + if (version < 0 || version > std::numeric_limits::max()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "version must be a non-negative integer"); + } + + resolved.alias = alias; + resolved.name = alias; + resolved.version = static_cast(version); + resolved.model_id = alias + ":" + std::to_string(version); + resolved.uri.clear(); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_REG_MODEL_PATH, model_path); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_REG_ALIAS, alias); + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, version); + if (!resolved.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR)) { + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR, "local"); + } + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR, "LocalRegistration"); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_ENTITY_TYPE_STR, "Model"); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR, "ONNX"); + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, UtcTimestamp(now)); + if (!resolved.GetPropertyStr(kRegistrationIdProperty)) { + const auto registration_id = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + SetModelInfoStringProperty(resolved, kRegistrationIdProperty, std::to_string(registration_id)); + } + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, DirectorySize(model_path)); + + const auto config_path = std::filesystem::path(model_path) / "genai_config.json"; + try { + if (std::filesystem::exists(config_path)) { + const auto config = GenAIConfig::LoadFromFile(config_path.string()); + if (config.model && config.model->context_length > 0 && + !resolved.GetPropertyInt(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT)) { + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, config.model->context_length); + } + // Keep the default provider in genai_config.json authoritative when the caller did not supply one. Some OGA + // providers such as DML are not represented by the SDK's explicit ExecutionProvider enum and use kDefault. + if (resolved.task.empty()) { + std::string task = "chat-completion"; + if (config.hidden_size) { + task = "embeddings"; + } else if (config.model && config.model->type == "whisper") { + task = "automatic-speech-recognition"; + } + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, task); + } + } + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Warning, "Ignoring BYOM metadata inspection failure for '" + model_path + "': " + ex.what()); + } + + if (resolved.task.empty()) { + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"); + } + if (!resolved.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR)) { + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, + resolved.task == "automatic-speech-recognition" ? "audio" : "language"); + } + if (!resolved.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR)) { + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "language"); + } + return resolved; +} + +std::vector LocalModelCatalog::LoadRegistrations() const { + std::vector registrations; + std::ifstream stream(index_path_, std::ios::binary); + if (!stream) { + return registrations; + } + + try { + nlohmann::json root; + stream >> root; + if (!root.is_object() || root.value("version", 0) != 1 || !root.contains("models") || !root["models"].is_array()) { + logger_.Log(LogLevel::Warning, "Ignoring malformed local model registration index: " + index_path_.string()); + return registrations; + } + + for (const auto& item : root["models"]) { + try { + if (!item.is_object() || !item.contains("model_path") || !item["model_path"].is_string() || + !item.contains("properties")) { + continue; + } + auto info = ModelInfoFromPropertyBagJson(item["properties"]); + const auto* alias = info.GetPropertyStr(FOUNDRY_LOCAL_REG_ALIAS); + if (!alias || !std::regex_match(*alias, std::regex("[A-Za-z0-9][A-Za-z0-9._-]*"))) { + continue; + } + const auto version = info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, int64_t{0}); + if (version < 0 || version > std::numeric_limits::max()) { + continue; + } + std::filesystem::path model_path = item["model_path"].get(); + if (model_path.empty() || HasParentTraversal(model_path)) { + continue; + } + model_path = std::filesystem::absolute(model_path).lexically_normal(); + info.alias = *alias; + info.name = *alias; + info.version = static_cast(version); + info.model_id = info.alias + ":" + std::to_string(info.version); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, model_path.string()); + const auto duplicate = std::find_if(registrations.begin(), registrations.end(), [&](const Registration& entry) { + return entry.info.alias == info.alias || entry.info.model_id == info.model_id; + }); + if (duplicate == registrations.end()) { + registrations.push_back({std::move(info), model_path.string()}); + } + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Warning, std::string("Ignoring malformed local model registration: ") + ex.what()); + } + } + } catch (const std::exception& ex) { + logger_.Log(LogLevel::Warning, std::string("Ignoring unreadable local model registration index: ") + ex.what()); + } + return registrations; +} + +void LocalModelCatalog::SaveRegistrations(const std::vector& registrations) const { + std::filesystem::create_directories(catalog_dir_); + nlohmann::json models = nlohmann::json::array(); + for (const auto& registration : registrations) { + models.push_back(RegistrationToJson(registration)); + } + const nlohmann::json root = {{"version", 1}, {"catalog_name", "local"}, {"models", std::move(models)}}; + const auto temp_path = index_path_.string() + ".tmp"; + { + std::ofstream stream(temp_path, std::ios::binary | std::ios::trunc); + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to write local model registration index"); + } + stream << root.dump(2) << '\n'; + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to write local model registration index"); + } + } +#ifdef _WIN32 + if (!MoveFileExW(std::filesystem::path(temp_path).wstring().c_str(), index_path_.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + std::filesystem::remove(temp_path); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to commit local model registration index"); + } +#else + std::error_code ec; + std::filesystem::rename(temp_path, index_path_, ec); + if (ec) { + std::filesystem::remove(temp_path); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to commit local model registration index: " + ec.message()); + } +#endif +} + +void LocalModelCatalog::WriteMetadata(const Registration& registration) const { + // This portable model-side metadata artifact is distinct from registration persistence. The flat catalog index is + // authoritative for membership, and unregistering never mutates user-owned model files. + const auto path = std::filesystem::path(registration.model_path); + std::error_code ec; + if (!std::filesystem::is_directory(path, ec)) { + return; + } + + const auto metadata_path = path / "model_metadata.yml"; + const auto temp_path = path / "model_metadata.yml.tmp"; + { + std::ofstream stream(temp_path, std::ios::binary | std::ios::trunc); + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + "failed to write model_metadata.yml beside BYOM assets: " + registration.model_path); + } + + const auto& info = registration.info; + stream << "schema_version: 1\n"; + stream << "name: " << EscapeYaml(info.name) << '\n'; + stream << "version: " << info.version << '\n'; + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR, "publisher"); + stream << "alias: " << EscapeYaml(info.alias) << '\n'; + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_DISPLAY_NAME_STR, "display_name"); + stream << "foundry_local: true\n"; + stream << "type: \"Model\"\n"; + stream << "model_type: \"ONNX\"\n"; + WriteOptionalYamlInt(stream, info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, "file_size_bytes"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, "creation_time"); + WriteOptionalYamlInt(stream, info, FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, "context_length"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_EP_STR, "execution_provider"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_DEVICE_TYPE_STR, "device"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "task"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_LICENSE_STR, "license"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_LICENSE_DESCRIPTION_STR, "license_description"); + WriteOptionalYamlInt(stream, info, FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT, "max_output_tokens"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, "input_modalities"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "output_modalities"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR, "min_foundry_local_version"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_AUTHOR_STR, "author"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_QUANTIZATION_STR, "quantization"); + WriteOptionalYamlString(stream, info, FOUNDRY_LOCAL_MODEL_PROP_CAPABILITIES_STR, "capabilities"); + const auto write_bool = [&](const char* property_key, const char* yaml_key) { + const auto* value = info.GetPropertyInt(property_key); + if (value) { + stream << yaml_key << ": " << (*value != 0 ? "true" : "false") << '\n'; + } + }; + write_bool(FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_TOOL_CALLING_INT, "supports_tool_calling"); + write_bool(FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_REASONING_INT, "supports_reasoning"); + write_bool(FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_HYBRID_REASONING_INT, "supports_hybrid_reasoning"); + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + "failed to write model_metadata.yml beside BYOM assets: " + registration.model_path); + } + } + +#ifdef _WIN32 + if (!MoveFileExW(temp_path.wstring().c_str(), metadata_path.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + std::filesystem::remove(temp_path); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to commit model_metadata.yml: " + registration.model_path); + } +#else + std::filesystem::rename(temp_path, metadata_path, ec); + if (ec) { + std::filesystem::remove(temp_path); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to commit model_metadata.yml: " + ec.message()); + } +#endif +} + +Model LocalModelCatalog::CreateModel(const Registration& registration) const { + const auto registration_id = registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); + return model_factory_( + registration.info, registration.model_path, + [this, registration_id](const std::string& model_id) { + auto* current = GetModelVariant(model_id); + if (!current || !current->IsActive() || + current->Info().GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) != registration_id) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + const_cast(this)->UnregisterModel(model_id); + }, + [this, registration]() { WriteMetadata(registration); }); +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.h b/sdk_v2/cpp/src/catalog/local_model_catalog.h new file mode 100644 index 000000000..b74accf20 --- /dev/null +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.h @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "catalog/base_model_catalog.h" + +#include +#include +#include + +namespace fl { + +/// Mutable, persistent catalog for models registered from arbitrary local directories. +class LocalModelCatalog final : public BaseModelCatalog { + public: + using ModelFactory = std::function, + std::function)>; + + LocalModelCatalog(std::filesystem::path app_data_dir, ModelFactory model_factory, ILogger& logger); + + Model* RegisterModel(const ModelInfo& model_info) override; + void UnregisterModel(const std::string& alias_or_model_id) override; + std::vector GetLocalModels() const override; + + struct Registration { + ModelInfo info; + std::string model_path; + }; + + protected: + std::vector FetchModels() const override; + + private: + ModelInfo ResolveMetadata(const ModelInfo& supplied, const std::string& model_path, const std::string& alias) const; + std::vector LoadRegistrations() const; + void SaveRegistrations(const std::vector& registrations) const; + void WriteMetadata(const Registration& registration) const; + Model CreateModel(const Registration& registration) const; + + std::filesystem::path catalog_dir_; + std::filesystem::path index_path_; + std::filesystem::path lock_path_; + ModelFactory model_factory_; + ILogger& logger_; + mutable std::mutex registration_mutex_; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/session/session.cc b/sdk_v2/cpp/src/inferencing/session/session.cc index e8f90ee69..464691811 100644 --- a/sdk_v2/cpp/src/inferencing/session/session.cc +++ b/sdk_v2/cpp/src/inferencing/session/session.cc @@ -48,7 +48,7 @@ std::unique_ptr Session::Create(const fl::Model& model) { } auto& lm = mgr.GetModelLoadManager(); - auto* loaded = lm.GetLoadedModel(model.Id()); + auto* loaded = lm.GetLoadedModel(model.RuntimeId()); if (!loaded) { FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, "loaded model not found in load manager"); } diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 70e67a6a0..ca8490df0 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -12,6 +12,7 @@ #include "catalog.h" #include "catalog/azure_model_catalog.h" +#include "catalog/local_model_catalog.h" #include "download/download_manager.h" #include "ep_detection/cuda_ep_bootstrapper.h" #include "ep_detection/ep_detector.h" @@ -327,7 +328,7 @@ Manager::Manager(const Configuration& config) model_load_manager_ = std::make_unique(*ep_detector_, *logger_); session_manager_ = std::make_unique(*logger_); telemetry_ = std::make_unique(config_.app_name, *logger_); - catalog_ = std::make_unique( + public_catalog_ = std::make_unique( config_.catalog_urls, download_manager_->GetCacheDirectory(), [this](ModelInfo info, std::string local_path) { @@ -337,6 +338,15 @@ Manager::Manager(const Configuration& config) config_.external_service_url.has_value(), config_.catalog_region.value_or("auto"), disable_region_fallback); + local_catalog_ = std::make_unique( + *config_.app_data_dir, + [this](ModelInfo info, std::string local_path, std::function unregister_callback, + std::function prepare_callback) { + return CreateLocalModel(std::move(info), std::move(local_path), std::move(unregister_callback), + std::move(prepare_callback)); + }, + *logger_); + local_catalog_->ListModels(); } Manager::~Manager() { @@ -361,7 +371,8 @@ Manager::~Manager() { session_manager_.reset(); model_load_manager_.reset(); download_manager_.reset(); - catalog_.reset(); + local_catalog_.reset(); + public_catalog_.reset(); telemetry_.reset(); ep_detector_.reset(); @@ -441,7 +452,30 @@ void Manager::Destroy() { } ICatalog& Manager::GetCatalog() { - return *catalog_; + return *public_catalog_; +} + +ICatalog& Manager::GetCatalog(CatalogType type) { + switch (type) { + case CatalogType::kPublic: + return *public_catalog_; + case CatalogType::kLocal: + return *local_catalog_; + case CatalogType::kPrivate: + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "no private catalog has been configured"); + default: + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "unknown catalog type"); + } +} + +ICatalog& Manager::GetCatalog(const std::string& catalog_name) { + if (catalog_name == "local") { + return *local_catalog_; + } + if (catalog_name == "public" || catalog_name == public_catalog_->GetName()) { + return *public_catalog_; + } + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "catalog not found: " + catalog_name); } void Manager::StartWebService() { @@ -457,7 +491,8 @@ void Manager::StartWebService() { ActionTracker tracker(Action::kCoreServiceStart, *telemetry_); #ifdef FOUNDRY_LOCAL_HAS_WEB_SERVICE - web_service_ = std::make_unique(*catalog_, *logger_, *config_.model_cache_dir, *model_load_manager_, + web_service_ = std::make_unique(*public_catalog_, *logger_, *config_.model_cache_dir, + *model_load_manager_, *session_manager_, *telemetry_, [this]() { Shutdown(); }); @@ -543,6 +578,14 @@ Model Manager::CreateModel(ModelInfo info, std::string local_path) { *model_load_manager_); } +Model Manager::CreateLocalModel(ModelInfo info, std::string local_path, + std::function unregister_callback, + std::function prepare_callback) { + return Model::FromLocalRegistration(std::move(info), std::move(local_path), *download_manager_, + *model_load_manager_, std::move(unregister_callback), + std::move(prepare_callback)); +} + DownloadManager& Manager::GetDownloadManager() { return *download_manager_; } @@ -579,7 +622,7 @@ EpDownloadResult Manager::DownloadAndRegisterEps( // EP registration changes which device/EP filters the catalog uses. // Invalidate so the next catalog query re-fetches with updated filters. if (result.success && !result.registered_eps.empty()) { - catalog_->InvalidateCache(); + public_catalog_->InvalidateCache(); } return result; diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index 4b5440db7..a0b6525ad 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -7,6 +7,7 @@ #include "logger.h" #include +#include #include #include #include @@ -25,6 +26,7 @@ namespace fl { // Forward declarations class ICatalog; +enum class CatalogType; class DownloadManager; class ITelemetry; class Model; @@ -49,6 +51,8 @@ class Manager { /// The catalog is owned by the manager and shared across all consumers /// (web service, C API, etc.) so model state (e.g. IsLoaded) is consistent. ICatalog& GetCatalog(); + ICatalog& GetCatalog(CatalogType type); + ICatalog& GetCatalog(const std::string& catalog_name); /// Get the configuration used to create this manager. const Configuration& GetConfiguration() const; @@ -137,7 +141,8 @@ class Manager { std::unique_ptr logger_; std::unique_ptr ep_detector_; std::unique_ptr telemetry_; - std::unique_ptr catalog_; + std::unique_ptr public_catalog_; + std::unique_ptr local_catalog_; std::unique_ptr download_manager_; std::unique_ptr model_load_manager_; std::unique_ptr session_manager_; @@ -151,6 +156,9 @@ class Manager { private: Model CreateModel(ModelInfo info, std::string local_path); + Model CreateLocalModel(ModelInfo info, std::string local_path, + std::function unregister_callback, + std::function prepare_callback); static std::mutex s_mutex_; static std::unique_ptr s_instance_; diff --git a/sdk_v2/cpp/src/model.cc b/sdk_v2/cpp/src/model.cc index 1f06201ce..5f421be8c 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -15,6 +15,7 @@ #include #include +#include namespace fl { @@ -107,7 +108,13 @@ Model::~Model() = default; Model::Model(Model&& other) noexcept : info_(std::move(other.info_)), cached_(other.cached_.load()), + active_(other.active_.load()), local_path_(std::move(other.local_path_)), + runtime_model_id_(std::move(other.runtime_model_id_)), + external_registration_(other.external_registration_), + unregister_callback_(std::move(other.unregister_callback_)), + prepare_callback_(std::move(other.prepare_callback_)), + metadata_prepared_(other.metadata_prepared_.load()), download_manager_(other.download_manager_), model_load_manager_(other.model_load_manager_), variants_(std::move(other.variants_)), @@ -122,7 +129,13 @@ Model& Model::operator=(Model&& other) noexcept { if (this != &other) { info_ = std::move(other.info_); cached_.store(other.cached_.load()); + active_.store(other.active_.load()); local_path_ = std::move(other.local_path_); + runtime_model_id_ = std::move(other.runtime_model_id_); + external_registration_ = other.external_registration_; + unregister_callback_ = std::move(other.unregister_callback_); + prepare_callback_ = std::move(other.prepare_callback_); + metadata_prepared_.store(other.metadata_prepared_.load()); download_manager_ = other.download_manager_; model_load_manager_ = other.model_load_manager_; variants_ = std::move(other.variants_); @@ -145,6 +158,7 @@ Model Model::FromModelInfo(ModelInfo info, ModelLoadManager& model_load_manager) { Model model; model.info_ = std::move(info); + model.runtime_model_id_ = model.info_.model_id; model.download_manager_ = &download_manager; model.model_load_manager_ = &model_load_manager; @@ -156,6 +170,22 @@ Model Model::FromModelInfo(ModelInfo info, return model; } +Model Model::FromLocalRegistration(ModelInfo info, + std::string local_path, + DownloadManager& download_manager, + ModelLoadManager& model_load_manager, + std::function unregister_callback, + std::function prepare_callback) { + auto model = FromModelInfo(std::move(info), std::move(local_path), download_manager, model_load_manager); + model.external_registration_ = true; + model.runtime_model_id_ = "local/" + model.info_.model_id; + model.unregister_callback_ = std::move(unregister_callback); + model.prepare_callback_ = std::move(prepare_callback); + model.metadata_prepared_.store( + std::filesystem::is_regular_file(std::filesystem::path(model.local_path_) / "model_metadata.yml")); + return model; +} + // --------------------------------------------------------------------------- // Container operations // --------------------------------------------------------------------------- @@ -255,6 +285,21 @@ bool Model::IsCached() const { return selected_variant_->IsCached(); } + if (!active_) { + return false; + } + + if (external_registration_) { + std::error_code ec; + const bool available = std::filesystem::is_directory(local_path_, ec) && + std::filesystem::is_regular_file( + std::filesystem::path(local_path_) / "genai_config.json", ec); + if (available) { + EnsureLocalMetadata(); + } + return available; + } + return cached_; } @@ -263,10 +308,14 @@ bool Model::IsLoaded() const { return selected_variant_->IsLoaded(); } + if (!active_) { + return false; + } + // ModelLoadManager owns the authoritative loaded-instance map. The pointer is set at // construction and never reassigned, so querying it here stays in sync with paths that // bypass Model::Load/Unload (e.g., Manager::Shutdown -> ModelLoadManager::UnloadAll). - return model_load_manager_->GetLoadedModel(info_.model_id) != nullptr; + return model_load_manager_->GetLoadedModel(runtime_model_id_) != nullptr; } // --------------------------------------------------------------------------- @@ -279,6 +328,17 @@ void Model::Download(std::function progress_cb) { return; } + if (!active_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + + if (external_registration_) { + if (progress_cb) { + progress_cb(100.0f); + } + return; + } + // Already cached (scanner found the model on disk during catalog construction). // No need to re-derive the path via DownloadManager — local_path_ is authoritative. if (cached_ && !local_path_.empty()) { @@ -309,9 +369,27 @@ void Model::Load(ExecutionProvider ep) { return; } + std::lock_guard lifecycle_lock(lifecycle_mutex_); + + if (!active_ || unregistering_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + + if (external_registration_ && ep == ExecutionProvider::kDefault && !info_.execution_provider.empty()) { + ep = EPUtils::StringtoEP(info_.execution_provider); + if (ep == ExecutionProvider::kUnknown) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + "unknown execution provider for local model: " + info_.execution_provider); + } + } + + if (external_registration_) { + EnsureLocalMetadata(); + } + // LoadModel is idempotent — it returns kModelAlreadyLoaded if the id is already // in the load manager's map, so no need for a local short-circuit. - auto result = model_load_manager_->LoadModel(local_path_, info_.model_id, ep); + auto result = model_load_manager_->LoadModel(local_path_, runtime_model_id_, ep); if (result.status == ModelLoadManager::LoadStatus::kModelNotFound) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "model not found at path: " + local_path_); @@ -324,8 +402,12 @@ void Model::Unload() { return; } + if (!active_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + // UnloadModel is idempotent — returns false if the id isn't loaded. - model_load_manager_->UnloadModel(info_.model_id); + model_load_manager_->UnloadModel(runtime_model_id_); } void Model::RemoveFromCache() { @@ -334,6 +416,22 @@ void Model::RemoveFromCache() { return; } + if (external_registration_) { + if (!active_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + if (IsLoaded()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "cannot unregister a loaded model; unload it first"); + } + + if (!unregister_callback_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "local model is missing its unregister callback"); + } + + unregister_callback_(info_.model_id); + return; + } + if (!cached_ || local_path_.empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is not cached locally"); } @@ -350,6 +448,53 @@ void Model::RemoveFromCache() { local_path_.clear(); } +void Model::Deactivate() { + active_.store(false); + if (IsContainer()) { + for (auto* variant : Variants()) { + variant->Deactivate(); + } + } +} + +void Model::EnsureLocalMetadata() const { + if (metadata_prepared_.load() || !prepare_callback_) { + return; + } + + prepare_callback_(); + metadata_prepared_.store( + std::filesystem::is_regular_file(std::filesystem::path(local_path_) / "model_metadata.yml")); +} + +void Model::BeginUnregister() { + if (selected_variant_) { + for (auto* variant : Variants()) { + variant->BeginUnregister(); + } + return; + } + + lifecycle_mutex_.lock(); + if (!active_ || unregistering_) { + lifecycle_mutex_.unlock(); + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + unregistering_ = true; +} + +void Model::CancelUnregister() { + if (selected_variant_) { + for (auto* variant : Variants()) { + variant->CancelUnregister(); + } + return; + } + + unregistering_ = false; + lifecycle_mutex_.unlock(); +} + void Model::SelectVariant(const Model& variant) { if (!IsContainer()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, diff --git a/sdk_v2/cpp/src/model.h b/sdk_v2/cpp/src/model.h index 90710768a..9dc273b68 100644 --- a/sdk_v2/cpp/src/model.h +++ b/sdk_v2/cpp/src/model.h @@ -51,6 +51,14 @@ class Model { DownloadManager& download_manager, ModelLoadManager& model_load_manager); + /// Create an in-place externally registered model. Assets are never deleted by this Model. + static Model FromLocalRegistration(ModelInfo info, + std::string local_path, + DownloadManager& download_manager, + ModelLoadManager& model_load_manager, + std::function unregister_callback, + std::function prepare_callback); + // --- Container construction --- /// Create a container Model wrapping the given variant as its first (and selected) variant. @@ -101,6 +109,7 @@ class Model { bool IsCached() const; bool IsLoaded() const; + bool IsActive() const { return selected_variant_ ? selected_variant_->IsActive() : active_.load(); } /// Get the supported input and output item types for this model, based on its task. /// Returns arrays of Item pointers (type-tag-only descriptors) from static storage. @@ -130,6 +139,11 @@ class Model { void Unload(); void RemoveFromCache(); + /// Mark this model and its variants inactive while retaining pointer validity. + void Deactivate(); + void BeginUnregister(); + void CancelUnregister(); + /// Select a specific variant within this container. Throws if the variant is /// not part of this model, or if this is a leaf. /// @@ -147,8 +161,13 @@ class Model { /// one-shot operations, so callers reading the path concurrently with download /// or removal of the same Model are out of contract. const std::string& LocalPath() const { return local_path_; } + const std::string& RuntimeId() const { + return selected_variant_ ? selected_variant_->RuntimeId() : runtime_model_id_; + } private: + void EnsureLocalMetadata() const; + // Leaf data (default/empty for containers). // cached_ is atomic — flipped concurrently by the download path. // Loaded state is NOT stored here; it is queried from ModelLoadManager so the load @@ -159,7 +178,13 @@ class Model { // mutation alongside reads on the same Model* is not a supported pattern. ModelInfo info_; std::atomic cached_{false}; + std::atomic active_{true}; std::string local_path_; + std::string runtime_model_id_; + bool external_registration_ = false; + std::function unregister_callback_; + std::function prepare_callback_; + mutable std::atomic metadata_prepared_{false}; // Non-owning service bindings for leaf operations. Set once at construction and never // reassigned; guaranteed non-null because FromModelInfo takes them by reference. @@ -174,6 +199,8 @@ class Model { // Guards variants_ across reader/writer threads (catalog refresh adding variants // while another thread enumerates via Variants()). mutable std::mutex state_mutex_; + mutable std::mutex lifecycle_mutex_; + bool unregistering_ = false; }; } // namespace fl diff --git a/sdk_v2/cpp/src/model_info.cc b/sdk_v2/cpp/src/model_info.cc index cc2f68f16..907c14f56 100644 --- a/sdk_v2/cpp/src/model_info.cc +++ b/sdk_v2/cpp/src/model_info.cc @@ -1,10 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #include "model_info.h" +#include "exception.h" +#include "util/string_utils.h" #include #include +#include +#include #include namespace fl { @@ -28,15 +32,16 @@ std::string DeviceTypeToString(DeviceType dt) { namespace { DeviceType DeviceTypeFromString(const std::string& s) { - if (s == "CPU") { + const auto lowered = ToLower(s); + if (lowered == "cpu") { return DeviceType::kCPU; } - if (s == "GPU") { + if (lowered == "gpu") { return DeviceType::kGPU; } - if (s == "NPU") { + if (lowered == "npu") { return DeviceType::kNPU; } @@ -342,4 +347,120 @@ nlohmann::json ModelInfoToJson(const ModelInfo& info) { return j; } +void SetModelInfoStringProperty(ModelInfo& info, std::string key, std::string value) { + if (key == FOUNDRY_LOCAL_REG_ALIAS) { + info.alias = value; + } else if (key == FOUNDRY_LOCAL_MODEL_PROP_TASK_STR) { + info.task = value; + } else if (key == FOUNDRY_LOCAL_MODEL_PROP_EP_STR) { + info.execution_provider = value; + } else if (key == FOUNDRY_LOCAL_MODEL_PROP_DEVICE_TYPE_STR) { + info.device_type = DeviceTypeFromString(value); + } + + info.string_properties[std::move(key)] = std::move(value); +} + +void SetModelInfoIntProperty(ModelInfo& info, std::string key, int64_t value) { + if (key == FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT) { + info.version = static_cast(value); + } + + info.int_properties[std::move(key)] = value; +} + +nlohmann::json ModelInfoToPropertyBagJson(const ModelInfo& info) { + nlohmann::json json = nlohmann::json::object(); + for (const auto& [key, value] : info.string_properties) { + json[key] = value; + } + + for (const auto& [key, value] : info.int_properties) { + json[key] = value; + } + + return json; +} + +ModelInfo ModelInfoFromPropertyBagJson(const nlohmann::json& json) { + if (!json.is_object()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model info JSON must contain an object"); + } + + ModelInfo info; + for (const auto& [key, value] : json.items()) { + if (value.is_number_integer()) { + SetModelInfoIntProperty(info, key, value.get()); + continue; + } + + if (!value.is_string()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model info property values must be strings or integers"); + } + + const auto text = value.get(); + const bool known_int = key == FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_TOOL_CALLING_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_REASONING_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_HYBRID_REASONING_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_IS_TEST_MODEL_INT || + key == FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT; + if (known_int) { + try { + size_t parsed = 0; + const auto integer = std::stoll(text, &parsed); + if (parsed != text.size()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "invalid integer model info property: " + key); + } + SetModelInfoIntProperty(info, key, integer); + } catch (const std::exception&) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "invalid integer model info property: " + key); + } + } else { + SetModelInfoStringProperty(info, key, text); + } + } + + return info; +} + +void SerializeModelInfoToFile(const ModelInfo& info, const std::filesystem::path& file_path) { + if (file_path.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model info file path must not be empty"); + } + + std::ofstream stream(file_path, std::ios::binary | std::ios::trunc); + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to open model info file for writing: " + file_path.string()); + } + + stream << ModelInfoToPropertyBagJson(info).dump(2) << '\n'; + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to write model info file: " + file_path.string()); + } +} + +ModelInfo DeserializeModelInfoFromFile(const std::filesystem::path& file_path) { + if (file_path.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model info file path must not be empty"); + } + + std::ifstream stream(file_path, std::ios::binary); + if (!stream) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "failed to open model info file: " + file_path.string()); + } + + try { + nlohmann::json json; + stream >> json; + return ModelInfoFromPropertyBagJson(json); + } catch (const nlohmann::json::exception& ex) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, std::string("failed to parse model info file: ") + ex.what()); + } +} + } // namespace fl diff --git a/sdk_v2/cpp/src/model_info.h b/sdk_v2/cpp/src/model_info.h index 09f279545..d4e72c090 100644 --- a/sdk_v2/cpp/src/model_info.h +++ b/sdk_v2/cpp/src/model_info.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -87,4 +88,14 @@ ModelInfo ModelInfoFromJson(const nlohmann::json& j); /// Serialize a ModelInfo to JSON. nlohmann::json ModelInfoToJson(const ModelInfo& info); +/// Set a property while keeping the typed ModelInfo fields synchronized with well-known keys. +void SetModelInfoStringProperty(ModelInfo& info, std::string key, std::string value); +void SetModelInfoIntProperty(ModelInfo& info, std::string key, int64_t value); + +/// Serialize the complete registration property bag. Unknown properties are preserved. +nlohmann::json ModelInfoToPropertyBagJson(const ModelInfo& info); +ModelInfo ModelInfoFromPropertyBagJson(const nlohmann::json& json); +void SerializeModelInfoToFile(const ModelInfo& info, const std::filesystem::path& file_path); +ModelInfo DeserializeModelInfoFromFile(const std::filesystem::path& file_path); + } // namespace fl diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index 3a5eb51b3..21da8a66a 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(foundry_local_tests internal_api/http_download_test.cc internal_api/http_retry_test.cc internal_api/item_test.cc + internal_api/local_model_catalog_test.cc internal_api/local_model_scanner_test.cc internal_api/model_info_test.cc internal_api/model_info_accessors_test.cc diff --git a/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc b/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc index 48ebdfe42..f5bfa3bc0 100644 --- a/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc +++ b/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc @@ -588,7 +588,7 @@ TEST(AzureCatalogClientTest, WithCachedModels_UnresolvedId_TriggersSecondFetch) EXPECT_TRUE(found_old); } -TEST(AzureCatalogClientTest, WithCachedModels_FullyUnresolved_CreatesBYOEntry) { +TEST(AzureCatalogClientTest, WithCachedModels_FullyUnresolved_DoesNotCreatePublicEntry) { CpuOnlyEpDetector ep; StderrLogger logger; int http_call_count = 0; @@ -610,21 +610,8 @@ TEST(AzureCatalogClientTest, WithCachedModels_FullyUnresolved_CreatesBYOEntry) { EXPECT_EQ(http_call_count, 2); - // Find the BYO entry. - const ModelInfo* byo = nullptr; - for (const auto& info : result) { - if (info.model_id == "custom-model:0") { - byo = &info; - } - } - - ASSERT_NE(byo, nullptr); - EXPECT_EQ(byo->name, "custom-model"); - EXPECT_EQ(byo->alias, "custom-model"); - EXPECT_EQ(byo->uri, "local://custom-model"); - EXPECT_EQ(byo->version, 0); - EXPECT_EQ(byo->string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR), "Local"); - EXPECT_EQ(byo->string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR), "ONNX"); + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result.front().model_id, "phi-4-mini:3"); } // ======================================================================== diff --git a/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc b/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc new file mode 100644 index 000000000..fc3ed5484 --- /dev/null +++ b/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "catalog/local_model_catalog.h" + +#include "internal_api/test_helpers.h" +#include "utils/temp_path.h" + +#include +#include + +#include +#include + +namespace fl::test { +namespace { + +class LocalModelCatalogTest : public ::testing::Test { + protected: + LocalModelCatalogTest() + : root_(TempPath::CreateTempDir("local_model_catalog")), + model_dir_(root_.path() / "model"), + catalog_(root_.path() / "appdata", + [this](ModelInfo info, std::string path, std::function unregister_callback, + std::function prepare_callback) { + return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()) { + std::filesystem::create_directories(model_dir_); + std::ofstream(model_dir_ / "genai_config.json") << R"({"model":{"type":"phi3","context_length":4096}})"; + } + + ModelInfo MakeInfo(std::string alias = "my-model") const { + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, model_dir_.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, std::move(alias)); + return info; + } + + TempPath root_; + std::filesystem::path model_dir_; + FakeServiceBindings bindings_; + LocalModelCatalog catalog_; +}; + +TEST_F(LocalModelCatalogTest, RegisterResolvesMetadataListsAndWritesFiles) { + auto* model = catalog_.RegisterModel(MakeInfo()); + + ASSERT_NE(model, nullptr); + EXPECT_EQ(model->Id(), "my-model:0"); + EXPECT_EQ(model->Alias(), "my-model"); + EXPECT_EQ(model->GetPath(), std::filesystem::absolute(model_dir_).lexically_normal().string()); + EXPECT_TRUE(model->IsCached()); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, -1), 4096); + EXPECT_EQ(catalog_.ListModels().size(), 1u); + EXPECT_EQ(catalog_.GetLocalModels().size(), 1u); + EXPECT_TRUE(std::filesystem::exists(model_dir_ / "model_metadata.yml")); + EXPECT_TRUE(std::filesystem::exists(root_.path() / "appdata" / "catalogs" / "local" / "local_models.json")); +} + +TEST_F(LocalModelCatalogTest, RejectsMissingInvalidAndDuplicateAliases) { + ModelInfo missing; + EXPECT_THROW(catalog_.RegisterModel(missing), Exception); + EXPECT_THROW(catalog_.RegisterModel(MakeInfo("bad alias")), Exception); + + catalog_.RegisterModel(MakeInfo()); + EXPECT_THROW(catalog_.RegisterModel(MakeInfo()), Exception); +} + +TEST_F(LocalModelCatalogTest, PersistsAndUnregistersWithoutDeletingAssets) { + catalog_.RegisterModel(MakeInfo()); + { + LocalModelCatalog restored( + root_.path() / "appdata", + [this](ModelInfo info, std::string path, std::function unregister_callback, + std::function prepare_callback) { + return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + ASSERT_EQ(restored.ListModels().size(), 1u); + restored.UnregisterModel("my-model"); + EXPECT_TRUE(restored.ListModels().empty()); + } + + EXPECT_TRUE(std::filesystem::exists(model_dir_ / "genai_config.json")); + LocalModelCatalog reloaded( + root_.path() / "appdata", + [this](ModelInfo info, std::string path, std::function unregister_callback, + std::function prepare_callback) { + return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + EXPECT_TRUE(reloaded.ListModels().empty()); +} + +TEST_F(LocalModelCatalogTest, MissingDirectoryRemainsListedButIsNotCached) { + catalog_.RegisterModel(MakeInfo()); + std::filesystem::remove_all(model_dir_); + + ASSERT_EQ(catalog_.ListModels().size(), 1u); + EXPECT_TRUE(catalog_.GetCachedModels().empty()); +} + +TEST_F(LocalModelCatalogTest, RegistrationDoesNotValidateMissingModelDirectory) { + const auto missing_path = root_.path() / "not-yet-provisioned"; + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, missing_path.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "deferred-model"); + + auto* model = catalog_.RegisterModel(info); + + ASSERT_NE(model, nullptr); + EXPECT_EQ(model->Id(), "deferred-model:0"); + EXPECT_FALSE(model->IsCached()); + EXPECT_EQ(catalog_.ListModels().size(), 1u); + EXPECT_TRUE(catalog_.GetCachedModels().empty()); + EXPECT_FALSE(std::filesystem::exists(missing_path)); + + std::filesystem::create_directories(missing_path); + std::ofstream(missing_path / "genai_config.json") << R"({"model":{"type":"phi3"}})"; + EXPECT_TRUE(model->IsCached()); + EXPECT_TRUE(std::filesystem::exists(missing_path / "model_metadata.yml")); +} + +TEST_F(LocalModelCatalogTest, PublicCatalogContractRejectsRegistration) { + class ReadOnlyCatalog final : public ICatalog { + public: + const std::string& GetName() const override { return name_; } + std::vector ListModels() const override { return {}; } + Model* GetModel(const std::string&) const override { return nullptr; } + Model* GetModelVariant(const std::string&) const override { return nullptr; } + Model* GetLatestVersion(const Model*) const override { return nullptr; } + std::vector GetModelVersions(const std::string&, const std::string&, int) override { return {}; } + std::vector GetCachedModels() const override { return {}; } + std::vector GetLoadedModels() const override { return {}; } + + private: + std::string name_ = "public"; + } catalog; + + EXPECT_THROW(catalog.RegisterModel(MakeInfo()), Exception); +} + +} // namespace +} // namespace fl::test diff --git a/sdk_v2/cpp/test/internal_api/model_info_test.cc b/sdk_v2/cpp/test/internal_api/model_info_test.cc index b91ce886e..d9615ffd5 100644 --- a/sdk_v2/cpp/test/internal_api/model_info_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_info_test.cc @@ -4,6 +4,7 @@ // Round-trip tests for ModelInfo JSON serialization/deserialization. // #include "model_info.h" +#include "utils/temp_path.h" #include #include @@ -11,6 +12,23 @@ using namespace fl; +TEST(ModelInfoPropertyBag, FileRoundTripPreservesKnownAndUnknownProperties) { + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "my-model"); + SetModelInfoStringProperty(info, "future_property", "future-value"); + SetModelInfoIntProperty(info, FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, 7); + + auto file = fl::test::TempPath::CreateTempFile("model_info"); + SerializeModelInfoToFile(info, file.path()); + auto restored = DeserializeModelInfoFromFile(file.path()); + + EXPECT_EQ(restored.GetPropertyWithDefault(FOUNDRY_LOCAL_REG_ALIAS, std::string{}), "my-model"); + EXPECT_EQ(restored.GetPropertyWithDefault("future_property", std::string{}), "future-value"); + EXPECT_EQ(restored.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, int64_t{-1}), 7); + EXPECT_EQ(restored.alias, "my-model"); + EXPECT_EQ(restored.version, 7); +} + // ======================================================================== // Reasoning fields round-trip // ======================================================================== From 15e1dde6a531a6b764dee0bbe77f53fde69b71f6 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:16:51 -0700 Subject: [PATCH 2/5] Add deep-copy semantics for ModelInfo Introduce ABI v3 with Info_Clone while preserving v1/v2 tables, restore independent C++ ModelInfo copy construction and assignment, and keep explicit CPU model loading on OGA's default provider. Add C ABI and C++ copy-semantics tests. --- .../include/foundry_local/foundry_local_c.h | 6 +- .../include/foundry_local/foundry_local_cpp.h | 3 + .../foundry_local/foundry_local_cpp.inline.h | 16 ++++ sdk_v2/cpp/src/c_api.cc | 96 ++++++++++++++++++- .../generative/genai_model_instance.cc | 5 +- sdk_v2/cpp/test/internal_api/c_api_test.cc | 61 ++++++++++++ .../internal_api/model_info_accessors_test.cc | 49 ++++++++++ 7 files changed, 230 insertions(+), 6 deletions(-) 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 2cd7fd1ed..743605353 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -60,7 +60,7 @@ * Incremented with each release. * Used to request the API function table via FoundryLocalGetApi. * ----------------------------------------------------------------------- */ -#define FOUNDRY_LOCAL_API_VERSION 2 +#define FOUNDRY_LOCAL_API_VERSION 3 /* ----------------------------------------------------------------------- * Platform export macros (C version) @@ -1081,6 +1081,10 @@ struct flModelApi { FL_API_STATUS(Info_DeserializeFromFile, _In_ const char* file_path, _Outptr_ flModelInfo** out_info); // End V2 + /// Create a caller-owned deep copy. Release it with ReleaseModelInfo. + FL_API_STATUS(Info_Clone, _In_ const flModelInfo* info, _Outptr_ flModelInfo** out_info); + + // End V3 }; #ifdef __cplusplus 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 05bc82c9c..c2a0fb948 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -315,6 +315,9 @@ class ModelInfo { ModelInfo(); explicit ModelInfo(const flModelInfo& info) noexcept : handle_(&info) {} + /// Create an independent, owning, mutable deep copy, including when the source is a borrowed view. + ModelInfo(const ModelInfo& other); + ModelInfo& operator=(const ModelInfo& other); ModelInfo(ModelInfo&&) noexcept = default; ModelInfo& operator=(ModelInfo&&) noexcept = default; 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 c0a95f469..e0aa55b29 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 @@ -327,6 +327,22 @@ inline ModelInfo::ModelInfo() inline ModelInfo::ModelInfo(flModelInfo& info) : handle_(&info, detail::model_api()->ReleaseModelInfo) {} +inline ModelInfo::ModelInfo(const ModelInfo& other) + : handle_([&other] { + flModelInfo* info = nullptr; + Check(detail::model_api()->Info_Clone(other.handle_.get(), &info)); + return info; + }(), detail::model_api()->ReleaseModelInfo) {} + +inline ModelInfo& ModelInfo::operator=(const ModelInfo& other) { + if (this != &other) { + ModelInfo clone(other); + *this = std::move(clone); + } + + return *this; +} + inline ModelInfo& ModelInfo::SetStringProperty(const char* key, const char* value) { Check(detail::model_api()->Info_SetStringProperty(handle_.get_mutable(), key, value)); return *this; diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index 543d46ecc..b7345533d 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -1107,6 +1107,22 @@ FL_API_STATUS_IMPL(Info_DeserializeFromFileImpl, const char* file_path, flModelI API_IMPL_END } +FL_API_STATUS_IMPL(Info_CloneImpl, const flModelInfo* info, flModelInfo** out_info) { + API_IMPL_BEGIN + if (!out_info) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "out_info must not be null"); + } + + *out_info = nullptr; + if (!info) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "info must not be null"); + } + + *out_info = AsHandle(new fl::ModelInfo(*AsImpl(info))); + return nullptr; + API_IMPL_END +} + static const flModelApi g_model_api_v1 = { Model_GetInfoImpl, Model_GetInputOutputInfoImpl, @@ -1133,7 +1149,7 @@ static const flModelApi g_model_api_v1 = { Info_GetIntPropertyImpl, }; -static const flModelApi g_model_api = { +static const flModelApi g_model_api_v2 = { Model_GetInfoImpl, Model_GetInputOutputInfoImpl, Model_IsCachedImpl, @@ -1163,6 +1179,39 @@ static const flModelApi g_model_api = { Info_SetIntPropertyImpl, Info_SerializeToFileImpl, Info_DeserializeFromFileImpl, + }; + + static const flModelApi g_model_api = { + Model_GetInfoImpl, + Model_GetInputOutputInfoImpl, + Model_IsCachedImpl, + Model_GetPathImpl, + Model_DownloadImpl, + Model_IsLoadedImpl, + Model_LoadImpl, + Model_UnloadImpl, + Model_RemoveFromCacheImpl, + Model_GetVariantsImpl, + Model_SelectVariantImpl, + Info_GetIdImpl, + Info_GetNameImpl, + Info_GetVersionImpl, + Info_GetAliasImpl, + Info_GetUriImpl, + Info_GetDeviceTypeImpl, + Info_GetExecutionProviderImpl, + Info_GetTaskImpl, + Info_GetPromptTemplatesImpl, + Info_GetModelSettingsImpl, + Info_GetStringPropertyImpl, + Info_GetIntPropertyImpl, + ModelInfo_CreateImpl, + ModelInfo_ReleaseImpl, + Info_SetStringPropertyImpl, + Info_SetIntPropertyImpl, + Info_SerializeToFileImpl, + Info_DeserializeFromFileImpl, + Info_CloneImpl, }; // ======================================================================== @@ -2016,6 +2065,10 @@ static const flModelApi* FL_API_CALL GetModelApiImpl() FL_NO_EXCEPTION { return &g_model_api; } +static const flModelApi* FL_API_CALL GetModelApiV2Impl() FL_NO_EXCEPTION { + return &g_model_api_v2; +} + static const flModelApi* FL_API_CALL GetModelApiV1Impl() FL_NO_EXCEPTION { return &g_model_api_v1; } @@ -2082,7 +2135,7 @@ static const flApi g_api_v1 = { GetConfigurationApiImpl, GetItemApiImpl, GetInferenceApiImpl, - GetModelApiImpl, + GetModelApiV2Impl, CreateKeyValuePairsImpl, AddKeyValuePairImpl, GetKeyValueImpl, @@ -2101,6 +2154,40 @@ static const flApi g_api_v1 = { Manager_GetCatalogByNameImpl, }; + static const flApi g_api_v3 = { + Status_CreateImpl, + Status_ReleaseImpl, + Status_GetErrorCodeImpl, + Status_GetErrorMessageImpl, + Manager_CreateImpl, + Manager_ReleaseImpl, + Manager_GetCatalogImpl, + Manager_WebServiceStartImpl, + Manager_WebServiceUrlsImpl, + Manager_WebServiceStopImpl, + GetCatalogApiImpl, + GetConfigurationApiImpl, + GetItemApiImpl, + GetInferenceApiImpl, + GetModelApiImpl, + CreateKeyValuePairsImpl, + AddKeyValuePairImpl, + GetKeyValueImpl, + GetKeyValuePairsImpl, + RemoveKeyValuePairImpl, + KeyValuePairs_ReleaseImpl, + ModelList_ReleaseImpl, + ModelList_SizeImpl, + ModelList_GetAtImpl, + Manager_GetDiscoverableEpsImpl, + Manager_DownloadAndRegisterEpsImpl, + Manager_IsEpDownloadInProgressImpl, + Manager_ShutdownImpl, + Manager_IsShutdownRequestedImpl, + Manager_GetCatalogByTypeImpl, + Manager_GetCatalogByNameImpl, + }; + // ======================================================================== // Exported symbols — the ONLY symbols the library exports // ======================================================================== @@ -2111,9 +2198,12 @@ FL_EXPORT const flApi* FL_API_CALL FoundryLocalGetApi(uint32_t version) FL_NO_EX if (version == 1) { return &g_api_v1; } - if (version == 0 || version == 2) { + if (version == 2) { return &g_api_v2; } + if (version == 0 || version == 3) { + return &g_api_v3; + } return nullptr; } diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc index 8f307c88a..cd9a66171 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc +++ b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc @@ -35,8 +35,9 @@ GenAIModelInstance::GenAIModelInstance(std::string model_id, "failed to create OGA config for model ", model_id_, ": ", e.what()); } - // Apply EP override to the OGA config - if (ep_ != ExecutionProvider::kDefault) { + // CPU is OGA's default when no provider is configured. EPtoGenAI intentionally has no CPU name, so only + // non-default accelerator overrides should replace the providers from genai_config.json. + if (ep_ != ExecutionProvider::kDefault && ep_ != ExecutionProvider::kCPU) { try { oga_config->ClearProviders(); std::string_view provider_str = EPUtils::EPtoGenAI(ep_); 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 993b9ac99..f688a6cb3 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -33,6 +33,20 @@ TEST(CApiTest, GetApiReturnsNullForFutureVersion) { EXPECT_EQ(api, nullptr); } +TEST(CApiTest, ModelInfoCloneIsAvailableOnlyInV3) { + const flApi* v2 = FoundryLocalGetApi(2); + const flApi* v3 = FoundryLocalGetApi(3); + ASSERT_NE(v2, nullptr); + ASSERT_NE(v3, nullptr); + + const flModelApi* model_v2 = v2->GetModelApi(); + const flModelApi* model_v3 = v3->GetModelApi(); + ASSERT_NE(model_v2, nullptr); + ASSERT_NE(model_v3, nullptr); + EXPECT_EQ(model_v2->Info_Clone, nullptr); + EXPECT_NE(model_v3->Info_Clone, nullptr); +} + TEST(CApiTest, VersionReturnsNonNull) { const char* version = FoundryLocalGetVersionString(); ASSERT_NE(version, nullptr); @@ -97,6 +111,53 @@ TEST(CApiTest, SubApiAccessorsReturnNonNull) { EXPECT_NE(api->GetModelApi(), nullptr); } +TEST(CApiTest, ModelInfoCloneCreatesIndependentDeepCopy) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flModelApi* model_api = api->GetModelApi(); + ASSERT_NE(model_api, nullptr); + ASSERT_NE(model_api->Info_Clone, nullptr); + + flModelInfo* source = nullptr; + ASSERT_TRUE(IsOk(model_api->CreateModelInfo(&source))); + ASSERT_NE(source, nullptr); + ASSERT_TRUE(IsOk(model_api->Info_SetStringProperty(source, "custom_string", "source"))); + ASSERT_TRUE(IsOk(model_api->Info_SetIntProperty(source, "custom_int", 42))); + + flModelInfo* clone = nullptr; + ASSERT_TRUE(IsOk(model_api->Info_Clone(source, &clone))); + ASSERT_NE(clone, nullptr); + EXPECT_NE(clone, source); + EXPECT_STREQ(model_api->Info_GetStringProperty(clone, "custom_string"), "source"); + EXPECT_EQ(model_api->Info_GetIntProperty(clone, "custom_int", -1), 42); + + ASSERT_TRUE(IsOk(model_api->Info_SetStringProperty(clone, "custom_string", "clone"))); + ASSERT_TRUE(IsOk(model_api->Info_SetIntProperty(clone, "custom_int", 99))); + EXPECT_STREQ(model_api->Info_GetStringProperty(source, "custom_string"), "source"); + EXPECT_EQ(model_api->Info_GetIntProperty(source, "custom_int", -1), 42); + + model_api->ReleaseModelInfo(clone); + model_api->ReleaseModelInfo(source); +} + +TEST(CApiTest, ModelInfoCloneValidatesArguments) { + const flApi* api = GetApi(); + const flModelApi* model_api = api->GetModelApi(); + + flModelInfo* clone = reinterpret_cast(1); + StatusGuard null_source{model_api->Info_Clone(nullptr, &clone), api}; + ASSERT_NE(null_source.s, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(null_source.s), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(clone, nullptr); + + flModelInfo* source = nullptr; + ASSERT_TRUE(IsOk(model_api->CreateModelInfo(&source))); + StatusGuard null_output{model_api->Info_Clone(source, nullptr), api}; + ASSERT_NE(null_output.s, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(null_output.s), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + model_api->ReleaseModelInfo(source); +} + // ======================================================================== // Configuration API // ======================================================================== diff --git a/sdk_v2/cpp/test/internal_api/model_info_accessors_test.cc b/sdk_v2/cpp/test/internal_api/model_info_accessors_test.cc index 4353c40c2..db7d6a767 100644 --- a/sdk_v2/cpp/test/internal_api/model_info_accessors_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_info_accessors_test.cc @@ -42,6 +42,55 @@ fl::ModelInfo MakeBareInfo() { } // namespace +TEST(ModelInfoCopy, OwningCopyIsIndependent) { + foundry_local::ModelInfo source; + source.SetStringProperty("custom_string", "source").SetIntProperty("custom_int", 42); + + foundry_local::ModelInfo copy(source); + copy.SetStringProperty("custom_string", "copy").SetIntProperty("custom_int", 99); + + EXPECT_EQ(source.GetStringProperty("custom_string"), "source"); + EXPECT_EQ(source.GetIntProperty("custom_int"), 42); + EXPECT_EQ(copy.GetStringProperty("custom_string"), "copy"); + EXPECT_EQ(copy.GetIntProperty("custom_int"), 99); +} + +TEST(ModelInfoCopy, BorrowedViewCopyBecomesOwningSnapshot) { + fl::ModelInfo internal = MakeBareInfo(); + internal.string_properties["custom_string"] = "borrowed"; + + auto borrowed = MakeView(internal); + foundry_local::ModelInfo snapshot = borrowed; + internal.string_properties["custom_string"] = "changed"; + + EXPECT_EQ(borrowed.GetStringProperty("custom_string"), "changed"); + EXPECT_EQ(snapshot.GetStringProperty("custom_string"), "borrowed"); + snapshot.SetStringProperty("custom_string", "snapshot"); + EXPECT_EQ(internal.string_properties["custom_string"], "changed"); +} + +TEST(ModelInfoCopy, CopyAssignmentHasIndependentValueSemantics) { + foundry_local::ModelInfo source; + source.SetStringProperty("custom_string", "source"); + foundry_local::ModelInfo destination; + destination.SetStringProperty("custom_string", "destination"); + + destination = source; + destination.SetStringProperty("custom_string", "assigned"); + + EXPECT_EQ(source.GetStringProperty("custom_string"), "source"); + EXPECT_EQ(destination.GetStringProperty("custom_string"), "assigned"); +} + +TEST(ModelInfoCopy, SelfAssignmentPreservesValue) { + foundry_local::ModelInfo info; + info.SetStringProperty("custom_string", "value"); + + info = info; + + EXPECT_EQ(info.GetStringProperty("custom_string"), "value"); +} + // ============================================================================ // ContextLength // ============================================================================ From c1f1b8b23fb6b9d96a426a3133fa31f3790fab6c Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:24:41 -0700 Subject: [PATCH 3/5] Fix the comment about "Deferred registrations" --- sdk_v2/cpp/src/catalog/local_model_catalog.cc | 107 ++++++-- sdk_v2/cpp/src/catalog/local_model_catalog.h | 9 +- sdk_v2/cpp/src/manager.cc | 4 +- sdk_v2/cpp/src/manager.h | 3 +- sdk_v2/cpp/src/model.cc | 72 +++-- sdk_v2/cpp/src/model.h | 10 +- .../internal_api/local_model_catalog_test.cc | 249 +++++++++++++++++- 7 files changed, 399 insertions(+), 55 deletions(-) diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index 378356c9a..dc30c7671 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -113,6 +113,7 @@ nlohmann::json RegistrationToJson(const LocalModelCatalog::Registration& registr {"model_path", registration.model_path}, {"registered_at", registration.info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, {})}, {"properties", ModelInfoToPropertyBagJson(registration.info)}, + {"metadata_prepared", registration.metadata_prepared}, }; } @@ -167,7 +168,16 @@ Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { } } - registration = {ResolveMetadata(model_info, model_path, *alias_value), model_path}; + bool assets_inspected = false; + registration = {ResolveMetadata(model_info, nullptr, model_path, *alias_value, &assets_inspected), model_path, + assets_inspected}; + auto registration_id = registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); + while (std::any_of(registrations.begin(), registrations.end(), [&](const Registration& existing) { + return existing.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == registration_id; + })) { + registration_id += "-1"; + } + SetModelInfoStringProperty(registration.info, kRegistrationIdProperty, std::move(registration_id)); WriteMetadata(registration); registrations.push_back(registration); SaveRegistrations(registrations); @@ -238,10 +248,13 @@ std::vector LocalModelCatalog::GetLocalModels() const { return ListModels(); } -ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& supplied, - const std::string& model_path, - const std::string& alias) const { - auto resolved = supplied; +ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const ModelInfo* previous, + const std::string& model_path, const std::string& alias, + bool* assets_inspected) const { + if (assets_inspected) { + *assets_inspected = false; + } + auto resolved = previous ? *previous : metadata; const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); const auto version = resolved.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, int64_t{0}); if (version < 0 || version > std::numeric_limits::max()) { @@ -262,33 +275,38 @@ ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& supplied, SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR, "LocalRegistration"); SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_ENTITY_TYPE_STR, "Model"); SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR, "ONNX"); - SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); - SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, UtcTimestamp(now)); - if (!resolved.GetPropertyStr(kRegistrationIdProperty)) { + if (!previous) { + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, UtcTimestamp(now)); + } + if (!previous) { const auto registration_id = std::chrono::high_resolution_clock::now().time_since_epoch().count(); SetModelInfoStringProperty(resolved, kRegistrationIdProperty, std::to_string(registration_id)); } - SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, DirectorySize(model_path)); - const auto config_path = std::filesystem::path(model_path) / "genai_config.json"; try { if (std::filesystem::exists(config_path)) { const auto config = GenAIConfig::LoadFromFile(config_path.string()); - if (config.model && config.model->context_length > 0 && - !resolved.GetPropertyInt(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT)) { + if (assets_inspected) { + *assets_inspected = true; + } + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, DirectorySize(model_path)); + resolved.int_properties.erase(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT); + if (config.model && config.model->context_length > 0) { SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, config.model->context_length); } // Keep the default provider in genai_config.json authoritative when the caller did not supply one. Some OGA // providers such as DML are not represented by the SDK's explicit ExecutionProvider enum and use kDefault. - if (resolved.task.empty()) { - std::string task = "chat-completion"; - if (config.hidden_size) { - task = "embeddings"; - } else if (config.model && config.model->type == "whisper") { - task = "automatic-speech-recognition"; - } - SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, task); + std::string task = "chat-completion"; + if (config.hidden_size) { + task = "embeddings"; + } else if (config.model && config.model->type == "whisper") { + task = "automatic-speech-recognition"; } + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, task); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, + task == "automatic-speech-recognition" ? "audio" : "language"); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "language"); } } catch (const std::exception& ex) { logger_.Log(LogLevel::Warning, "Ignoring BYOM metadata inspection failure for '" + model_path + "': " + ex.what()); @@ -328,7 +346,17 @@ std::vector LocalModelCatalog::LoadRegistration !item.contains("properties")) { continue; } + if (item.contains("metadata_prepared") && !item["metadata_prepared"].is_boolean()) { + logger_.Log(LogLevel::Warning, "Ignoring local model registration with invalid metadata preparation state"); + continue; + } + auto info = ModelInfoFromPropertyBagJson(item["properties"]); + const auto* registration_id = info.GetPropertyStr(kRegistrationIdProperty); + if (!registration_id || registration_id->empty()) { + logger_.Log(LogLevel::Warning, "Ignoring local model registration missing its stable registration ID"); + continue; + } const auto* alias = info.GetPropertyStr(FOUNDRY_LOCAL_REG_ALIAS); if (!alias || !std::regex_match(*alias, std::regex("[A-Za-z0-9][A-Za-z0-9._-]*"))) { continue; @@ -348,10 +376,12 @@ std::vector LocalModelCatalog::LoadRegistration info.model_id = info.alias + ":" + std::to_string(info.version); SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, model_path.string()); const auto duplicate = std::find_if(registrations.begin(), registrations.end(), [&](const Registration& entry) { - return entry.info.alias == info.alias || entry.info.model_id == info.model_id; + return entry.info.alias == info.alias || entry.info.model_id == info.model_id || + entry.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == *registration_id; }); if (duplicate == registrations.end()) { - registrations.push_back({std::move(info), model_path.string()}); + registrations.push_back( + {std::move(info), model_path.string(), item.value("metadata_prepared", false)}); } } catch (const std::exception& ex) { logger_.Log(LogLevel::Warning, std::string("Ignoring malformed local model registration: ") + ex.what()); @@ -363,6 +393,37 @@ std::vector LocalModelCatalog::LoadRegistration return registrations; } +std::optional LocalModelCatalog::PrepareRegistrationMetadata(const std::string& registration_id) const { + std::lock_guard guard(registration_mutex_); + FileLock file_lock(lock_path_); + auto registrations = LoadRegistrations(); + auto it = std::find_if(registrations.begin(), registrations.end(), [&](const Registration& registration) { + return registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == registration_id; + }); + if (it == registrations.end()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + if (it->metadata_prepared) { + const auto metadata_path = std::filesystem::path(it->model_path) / "model_metadata.yml"; + if (!std::filesystem::is_regular_file(metadata_path)) { + WriteMetadata(*it); + } + return it->info; + } + + bool assets_inspected = false; + auto refreshed = ResolveMetadata(it->info, &it->info, it->model_path, it->info.alias, &assets_inspected); + if (!assets_inspected) { + return std::nullopt; + } + + it->info = std::move(refreshed); + it->metadata_prepared = true; + WriteMetadata(*it); + SaveRegistrations(registrations); + return it->info; +} + void LocalModelCatalog::SaveRegistrations(const std::vector& registrations) const { std::filesystem::create_directories(catalog_dir_); nlohmann::json models = nlohmann::json::array(); @@ -482,7 +543,7 @@ Model LocalModelCatalog::CreateModel(const Registration& registration) const { } const_cast(this)->UnregisterModel(model_id); }, - [this, registration]() { WriteMetadata(registration); }); + [this, registration_id]() { return PrepareRegistrationMetadata(registration_id); }); } } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.h b/sdk_v2/cpp/src/catalog/local_model_catalog.h index b74accf20..78e73ca6e 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.h @@ -7,6 +7,7 @@ #include #include #include +#include namespace fl { @@ -14,7 +15,7 @@ namespace fl { class LocalModelCatalog final : public BaseModelCatalog { public: using ModelFactory = std::function, - std::function)>; + std::function()>)>; LocalModelCatalog(std::filesystem::path app_data_dir, ModelFactory model_factory, ILogger& logger); @@ -25,13 +26,17 @@ class LocalModelCatalog final : public BaseModelCatalog { struct Registration { ModelInfo info; std::string model_path; + bool metadata_prepared = false; }; protected: std::vector FetchModels() const override; private: - ModelInfo ResolveMetadata(const ModelInfo& supplied, const std::string& model_path, const std::string& alias) const; + ModelInfo ResolveMetadata(const ModelInfo& metadata, const ModelInfo* previous, const std::string& model_path, + const std::string& alias, + bool* assets_inspected = nullptr) const; + std::optional PrepareRegistrationMetadata(const std::string& registration_id) const; std::vector LoadRegistrations() const; void SaveRegistrations(const std::vector& registrations) const; void WriteMetadata(const Registration& registration) const; diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 8392154e5..8fb8b23ed 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -317,7 +317,7 @@ Manager::Manager(const Configuration& config) local_catalog_ = std::make_unique( *config_.app_data_dir, [this](ModelInfo info, std::string local_path, std::function unregister_callback, - std::function prepare_callback) { + std::function()> prepare_callback) { return CreateLocalModel(std::move(info), std::move(local_path), std::move(unregister_callback), std::move(prepare_callback)); }, @@ -556,7 +556,7 @@ Model Manager::CreateModel(ModelInfo info, std::string local_path) { Model Manager::CreateLocalModel(ModelInfo info, std::string local_path, std::function unregister_callback, - std::function prepare_callback) { + std::function()> prepare_callback) { return Model::FromLocalRegistration(std::move(info), std::move(local_path), *download_manager_, *model_load_manager_, std::move(unregister_callback), std::move(prepare_callback)); diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index a0b6525ad..17ed94862 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -158,7 +159,7 @@ class Manager { Model CreateModel(ModelInfo info, std::string local_path); Model CreateLocalModel(ModelInfo info, std::string local_path, std::function unregister_callback, - std::function prepare_callback); + std::function()> prepare_callback); static std::mutex s_mutex_; static std::unique_ptr s_instance_; diff --git a/sdk_v2/cpp/src/model.cc b/sdk_v2/cpp/src/model.cc index 69fbcff3c..a9368f469 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -106,8 +106,7 @@ bool CompareModelsForSort(const Model& m1, const Model& m2) { Model::~Model() = default; Model::Model(Model&& other) noexcept - : info_(std::move(other.info_)), - cached_(other.cached_.load()), + : cached_(other.cached_.load()), active_(other.active_.load()), local_path_(std::move(other.local_path_)), runtime_model_id_(std::move(other.runtime_model_id_)), @@ -119,6 +118,11 @@ Model::Model(Model&& other) noexcept model_load_manager_(other.model_load_manager_), variants_(std::move(other.variants_)), selected_variant_(other.selected_variant_.load(std::memory_order_relaxed)) { + { + std::lock_guard lock(other.metadata_mutex_); + info_snapshots_ = std::move(other.info_snapshots_); + } + current_info_.store(other.current_info_.load(std::memory_order_relaxed), std::memory_order_relaxed); // After vector move, selected_variant_ still points into the transferred buffer. other.download_manager_ = nullptr; other.model_load_manager_ = nullptr; @@ -127,7 +131,11 @@ Model::Model(Model&& other) noexcept Model& Model::operator=(Model&& other) noexcept { if (this != &other) { - info_ = std::move(other.info_); + { + std::scoped_lock lock(metadata_mutex_, other.metadata_mutex_); + info_snapshots_ = std::move(other.info_snapshots_); + } + current_info_.store(other.current_info_.load(std::memory_order_relaxed), std::memory_order_relaxed); cached_.store(other.cached_.load()); active_.store(other.active_.load()); local_path_ = std::move(other.local_path_); @@ -157,8 +165,8 @@ Model Model::FromModelInfo(ModelInfo info, DownloadManager& download_manager, ModelLoadManager& model_load_manager) { Model model; - model.info_ = std::move(info); - model.runtime_model_id_ = model.info_.model_id; + model.runtime_model_id_ = info.model_id; + model.PublishInfo(std::move(info)); model.download_manager_ = &download_manager; model.model_load_manager_ = &model_load_manager; @@ -175,14 +183,13 @@ Model Model::FromLocalRegistration(ModelInfo info, DownloadManager& download_manager, ModelLoadManager& model_load_manager, std::function unregister_callback, - std::function prepare_callback) { + std::function()> prepare_callback) { auto model = FromModelInfo(std::move(info), std::move(local_path), download_manager, model_load_manager); model.external_registration_ = true; - model.runtime_model_id_ = "local/" + model.info_.model_id; + model.runtime_model_id_ = "local/" + model.Info().model_id; model.unregister_callback_ = std::move(unregister_callback); model.prepare_callback_ = std::move(prepare_callback); - model.metadata_prepared_.store( - std::filesystem::is_regular_file(std::filesystem::path(model.local_path_) / "model_metadata.yml")); + model.metadata_prepared_.store(false); return model; } @@ -243,7 +250,7 @@ const std::string& Model::Id() const { return sv->Id(); } - return info_.model_id; + return Info().model_id; } const std::string& Model::Alias() const { @@ -251,7 +258,7 @@ const std::string& Model::Alias() const { return sv->Alias(); } - return info_.alias; + return Info().alias; } const ModelInfo& Model::Info() const { @@ -259,7 +266,11 @@ const ModelInfo& Model::Info() const { return sv->Info(); } - return info_; + const auto* info = current_info_.load(std::memory_order_acquire); + if (!info) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "model metadata is not initialized"); + } + return *info; } std::vector Model::Variants() const { @@ -356,7 +367,7 @@ void Model::Download(std::function progress_cb) { return; } - auto path = download_manager_->DownloadModel(info_, std::move(progress_cb)); + auto path = download_manager_->DownloadModel(Info(), std::move(progress_cb)); { std::lock_guard lock(state_mutex_); local_path_ = std::move(path); @@ -386,11 +397,12 @@ void Model::Load(ExecutionProvider ep) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); } - if (external_registration_ && ep == ExecutionProvider::kDefault && !info_.execution_provider.empty()) { - ep = EPUtils::StringtoEP(info_.execution_provider); + const auto& info = Info(); + if (external_registration_ && ep == ExecutionProvider::kDefault && !info.execution_provider.empty()) { + ep = EPUtils::StringtoEP(info.execution_provider); if (ep == ExecutionProvider::kUnknown) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, - "unknown execution provider for local model: " + info_.execution_provider); + "unknown execution provider for local model: " + info.execution_provider); } } @@ -439,7 +451,7 @@ void Model::RemoveFromCache() { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "local model is missing its unregister callback"); } - unregister_callback_(info_.model_id); + unregister_callback_(Info().model_id); return; } @@ -482,9 +494,29 @@ void Model::EnsureLocalMetadata() const { return; } - prepare_callback_(); - metadata_prepared_.store( - std::filesystem::is_regular_file(std::filesystem::path(local_path_) / "model_metadata.yml")); + std::lock_guard lock(metadata_mutex_); + if (metadata_prepared_.load()) { + return; + } + + auto refreshed = prepare_callback_(); + if (!refreshed) { + return; + } + + auto snapshot = std::make_unique(std::move(*refreshed)); + const auto* snapshot_ptr = snapshot.get(); + info_snapshots_.push_back(std::move(snapshot)); + current_info_.store(snapshot_ptr, std::memory_order_release); + metadata_prepared_.store(true); +} + +void Model::PublishInfo(ModelInfo info) { + auto snapshot = std::make_unique(std::move(info)); + const auto* snapshot_ptr = snapshot.get(); + std::lock_guard lock(metadata_mutex_); + info_snapshots_.push_back(std::move(snapshot)); + current_info_.store(snapshot_ptr, std::memory_order_release); } void Model::BeginUnregister() { diff --git a/sdk_v2/cpp/src/model.h b/sdk_v2/cpp/src/model.h index 3f0fca7aa..e566e78e8 100644 --- a/sdk_v2/cpp/src/model.h +++ b/sdk_v2/cpp/src/model.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -57,7 +58,7 @@ class Model { DownloadManager& download_manager, ModelLoadManager& model_load_manager, std::function unregister_callback, - std::function prepare_callback); + std::function()> prepare_callback); // --- Container construction --- @@ -171,6 +172,7 @@ class Model { private: void EnsureLocalMetadata() const; + void PublishInfo(ModelInfo info); // Leaf data (default/empty for containers). // cached_ is atomic — flipped concurrently by the download path. @@ -181,14 +183,16 @@ class Model { // cleared by RemoveFromCache(). Its mutation is guarded by state_mutex_; the reader-safety // contract is that the path is published before cached_ flips true (and cleared after cached_ // flips false), so any reader that gates on IsCached() observes a complete path. - ModelInfo info_; + mutable std::mutex metadata_mutex_; + mutable std::vector> info_snapshots_; + mutable std::atomic current_info_{nullptr}; std::atomic cached_{false}; std::atomic active_{true}; std::string local_path_; std::string runtime_model_id_; bool external_registration_ = false; std::function unregister_callback_; - std::function prepare_callback_; + std::function()> prepare_callback_; mutable std::atomic metadata_prepared_{false}; // Non-owning service bindings for leaf operations. Set once at construction and never diff --git a/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc b/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc index fc3ed5484..745464332 100644 --- a/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc +++ b/sdk_v2/cpp/test/internal_api/local_model_catalog_test.cc @@ -10,6 +10,8 @@ #include #include +#include +#include namespace fl::test { namespace { @@ -21,7 +23,7 @@ class LocalModelCatalogTest : public ::testing::Test { model_dir_(root_.path() / "model"), catalog_(root_.path() / "appdata", [this](ModelInfo info, std::string path, std::function unregister_callback, - std::function prepare_callback) { + std::function()> prepare_callback) { return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, bindings_.model_load_manager, std::move(unregister_callback), std::move(prepare_callback)); @@ -56,7 +58,15 @@ TEST_F(LocalModelCatalogTest, RegisterResolvesMetadataListsAndWritesFiles) { EXPECT_EQ(catalog_.ListModels().size(), 1u); EXPECT_EQ(catalog_.GetLocalModels().size(), 1u); EXPECT_TRUE(std::filesystem::exists(model_dir_ / "model_metadata.yml")); - EXPECT_TRUE(std::filesystem::exists(root_.path() / "appdata" / "catalogs" / "local" / "local_models.json")); + const auto index_path = root_.path() / "appdata" / "catalogs" / "local" / "local_models.json"; + ASSERT_TRUE(std::filesystem::exists(index_path)); + nlohmann::json index; + std::ifstream(index_path) >> index; + EXPECT_EQ(index["version"], 1); + ASSERT_EQ(index["models"].size(), 1u); + EXPECT_TRUE(index["models"][0].contains("properties")); + EXPECT_FALSE(index["models"][0].contains("supplied_properties")); + EXPECT_TRUE(index["models"][0].contains("metadata_prepared")); } TEST_F(LocalModelCatalogTest, RejectsMissingInvalidAndDuplicateAliases) { @@ -74,7 +84,7 @@ TEST_F(LocalModelCatalogTest, PersistsAndUnregistersWithoutDeletingAssets) { LocalModelCatalog restored( root_.path() / "appdata", [this](ModelInfo info, std::string path, std::function unregister_callback, - std::function prepare_callback) { + std::function()> prepare_callback) { return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, bindings_.model_load_manager, std::move(unregister_callback), std::move(prepare_callback)); @@ -89,7 +99,7 @@ TEST_F(LocalModelCatalogTest, PersistsAndUnregistersWithoutDeletingAssets) { LocalModelCatalog reloaded( root_.path() / "appdata", [this](ModelInfo info, std::string path, std::function unregister_callback, - std::function prepare_callback) { + std::function()> prepare_callback) { return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, bindings_.model_load_manager, std::move(unregister_callback), std::move(prepare_callback)); @@ -127,6 +137,237 @@ TEST_F(LocalModelCatalogTest, RegistrationDoesNotValidateMissingModelDirectory) EXPECT_TRUE(std::filesystem::exists(missing_path / "model_metadata.yml")); } +TEST_F(LocalModelCatalogTest, DeferredWhisperAssetsRefreshLiveAndPersistedMetadata) { + const auto deferred_path = root_.path() / "deferred-whisper"; + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, deferred_path.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "deferred-whisper"); + auto* model = catalog_.RegisterModel(info); + const auto* original_info = &model->Info(); + EXPECT_EQ(original_info->task, "chat-completion"); + + std::filesystem::create_directories(deferred_path); + std::ofstream(deferred_path / "genai_config.json") + << R"({"model":{"type":"whisper","context_length":448}})"; + + EXPECT_TRUE(model->IsCached()); + EXPECT_EQ(model->Info().task, "automatic-speech-recognition"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, std::string{}), + "audio"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, int64_t{-1}), 448); + EXPECT_EQ(original_info->task, "chat-completion"); + + LocalModelCatalog restored( + root_.path() / "appdata", + [this](ModelInfo restored_info, std::string path, + std::function unregister_callback, + std::function()> prepare_callback) { + return Model::FromLocalRegistration(std::move(restored_info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + auto restored_models = restored.ListModels(); + ASSERT_EQ(restored_models.size(), 1u); + EXPECT_EQ(restored_models.front()->Info().task, "automatic-speech-recognition"); +} + +TEST_F(LocalModelCatalogTest, ExistingEmptyDirectoryStillRefreshesWhenAssetsAppear) { + const auto deferred_path = root_.path() / "existing-deferred-whisper"; + std::filesystem::create_directories(deferred_path); + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, deferred_path.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "existing-deferred-whisper"); + auto* model = catalog_.RegisterModel(info); + EXPECT_TRUE(std::filesystem::exists(deferred_path / "model_metadata.yml")); + EXPECT_EQ(model->Info().task, "chat-completion"); + + std::ofstream(deferred_path / "genai_config.json") + << R"({"model":{"type":"whisper","context_length":448}})"; + + EXPECT_TRUE(model->IsCached()); + EXPECT_EQ(model->Info().task, "automatic-speech-recognition"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, std::string{}), + "audio"); +} + +TEST_F(LocalModelCatalogTest, DeferredAssetsAddedWhileStoppedRefreshAfterRestore) { + const auto deferred_path = root_.path() / "stopped-deferred-whisper"; + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, deferred_path.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "stopped-deferred-whisper"); + catalog_.RegisterModel(info); + + std::filesystem::create_directories(deferred_path); + std::ofstream(deferred_path / "genai_config.json") + << R"({"model":{"type":"whisper","context_length":448}})"; + + LocalModelCatalog restored( + root_.path() / "appdata", + [this](ModelInfo restored_info, std::string path, + std::function unregister_callback, + std::function()> prepare_callback) { + return Model::FromLocalRegistration(std::move(restored_info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + auto models = restored.ListModels(); + ASSERT_EQ(models.size(), 1u); + + EXPECT_TRUE(models.front()->IsCached()); + EXPECT_EQ(models.front()->Info().task, "automatic-speech-recognition"); + EXPECT_EQ(models.front()->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, int64_t{-1}), + 448); +} + +TEST_F(LocalModelCatalogTest, RestoreRepairsMissingMetadataSidecar) { + catalog_.RegisterModel(MakeInfo()); + ASSERT_TRUE(std::filesystem::remove(model_dir_ / "model_metadata.yml")); + + LocalModelCatalog restored( + root_.path() / "appdata", + [this](ModelInfo restored_info, std::string path, + std::function unregister_callback, + std::function()> prepare_callback) { + return Model::FromLocalRegistration(std::move(restored_info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + auto models = restored.ListModels(); + ASSERT_EQ(models.size(), 1u); + + EXPECT_TRUE(models.front()->IsCached()); + EXPECT_TRUE(std::filesystem::exists(model_dir_ / "model_metadata.yml")); + EXPECT_TRUE(models.front()->IsCached()); +} + +TEST_F(LocalModelCatalogTest, MalformedDeferredConfigRetriesAfterCorrection) { + const auto deferred_path = root_.path() / "malformed-deferred-whisper"; + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, deferred_path.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "malformed-deferred-whisper"); + SetModelInfoIntProperty(info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, 1234); + auto* model = catalog_.RegisterModel(info); + + std::filesystem::create_directories(deferred_path); + std::ofstream(deferred_path / "genai_config.json") << R"({"model":)"; + EXPECT_TRUE(model->IsCached()); + EXPECT_EQ(model->Info().task, "chat-completion"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, int64_t{-1}), 1234); + + std::ofstream(deferred_path / "genai_config.json", std::ios::trunc) + << R"({"model":{"type":"whisper","context_length":448}})"; + EXPECT_TRUE(model->IsCached()); + EXPECT_EQ(model->Info().task, "automatic-speech-recognition"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, int64_t{-1}), 448); + EXPECT_NE(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_BYTES_INT, int64_t{-1}), 1234); +} + +TEST_F(LocalModelCatalogTest, RegistrationWithoutPreparationStateRefreshesFromAssets) { + const auto model_path = root_.path() / "old-model"; + const auto catalog_dir = root_.path() / "old-appdata" / "catalogs" / "local"; + std::filesystem::create_directories(catalog_dir); + nlohmann::json properties = { + {FOUNDRY_LOCAL_REG_MODEL_PATH, model_path.string()}, + {FOUNDRY_LOCAL_REG_ALIAS, "old-model"}, + {FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"}, + {"_local_registration_id", "old-model-registration"}, + }; + nlohmann::json index = { + {"version", 1}, + {"catalog_name", "local"}, + {"models", {{{"alias", "old-model"}, {"model_path", model_path.string()}, {"properties", properties}}}}, + }; + std::ofstream(catalog_dir / "local_models.json") << index.dump(2); + + LocalModelCatalog restored( + root_.path() / "old-appdata", + [this](ModelInfo restored_info, std::string path, + std::function unregister_callback, + std::function()> prepare_callback) { + return Model::FromLocalRegistration(std::move(restored_info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + auto models = restored.ListModels(); + ASSERT_EQ(models.size(), 1u); + + std::filesystem::create_directories(model_path); + std::ofstream(model_path / "genai_config.json") + << R"({"model":{"type":"whisper","context_length":448}})"; + EXPECT_TRUE(models.front()->IsCached()); + EXPECT_EQ(models.front()->Info().task, "automatic-speech-recognition"); + EXPECT_EQ(models.front()->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, int64_t{-1}), + 448); +} + +TEST_F(LocalModelCatalogTest, IgnoresRestoredRegistrationWithDuplicateStableId) { + const auto catalog_dir = root_.path() / "duplicate-id-appdata" / "catalogs" / "local"; + std::filesystem::create_directories(catalog_dir); + const auto make_properties = [&](const std::string& alias) { + return nlohmann::json{ + {FOUNDRY_LOCAL_REG_MODEL_PATH, (root_.path() / alias).string()}, + {FOUNDRY_LOCAL_REG_ALIAS, alias}, + {FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"}, + {"_local_registration_id", "duplicate-registration-id"}, + }; + }; + nlohmann::json index = { + {"version", 1}, + {"catalog_name", "local"}, + {"models", + {{{"alias", "first"}, {"model_path", (root_.path() / "first").string()}, {"properties", make_properties("first")}}, + {{"alias", "second"}, + {"model_path", (root_.path() / "second").string()}, + {"properties", make_properties("second")}}}}, + }; + std::ofstream(catalog_dir / "local_models.json") << index.dump(2); + + LocalModelCatalog restored( + root_.path() / "duplicate-id-appdata", + [this](ModelInfo restored_info, std::string path, + std::function unregister_callback, + std::function()> prepare_callback) { + return Model::FromLocalRegistration(std::move(restored_info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(unregister_callback), + std::move(prepare_callback)); + }, + NullLog()); + + auto models = restored.ListModels(); + ASSERT_EQ(models.size(), 1u); + EXPECT_EQ(models.front()->Alias(), "first"); +} + + TEST_F(LocalModelCatalogTest, DeferredEmbeddingsAssetsOverrideRuntimeMetadataAndPreserveDescription) { + const auto deferred_path = root_.path() / "deferred-embeddings"; + ModelInfo info; + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, deferred_path.string()); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, "deferred-embeddings"); + SetModelInfoIntProperty(info, FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, 1234); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "automatic-speech-recognition"); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, "audio"); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_DISPLAY_NAME_STR, "My Embeddings Model"); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_LICENSE_STR, "MIT"); + auto* model = catalog_.RegisterModel(info); + + std::filesystem::create_directories(deferred_path); + std::ofstream(deferred_path / "genai_config.json") + << R"({"model":{"type":"bert","hidden_size":384,"context_length":512}})"; + + EXPECT_TRUE(model->IsCached()); + EXPECT_EQ(model->Info().task, "embeddings"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT, int64_t{-1}), 512); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, std::string{}), + "language"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_DISPLAY_NAME_STR, std::string{}), + "My Embeddings Model"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_LICENSE_STR, std::string{}), "MIT"); +} + TEST_F(LocalModelCatalogTest, PublicCatalogContractRejectsRegistration) { class ReadOnlyCatalog final : public ICatalog { public: From 0c6a72943ee600b1ef215cee0d9596f04ffe3142 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:37:34 -0700 Subject: [PATCH 4/5] fixed execution-provider override bug --- .../inferencing/generative/genai_model_instance.cc | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc index a85abe959..1e450d946 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc +++ b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc @@ -35,13 +35,15 @@ GenAIModelInstance::GenAIModelInstance(std::string model_id, "failed to create OGA config for model ", model_id_, ": ", e.what()); } - // CPU is OGA's default when no provider is configured. EPtoGenAI intentionally has no CPU name, so only - // non-default accelerator overrides should replace the providers from genai_config.json. - if (ep_ != ExecutionProvider::kDefault && ep_ != ExecutionProvider::kCPU) { + // Every explicit EP overrides providers from genai_config.json. CPU is OGA's default when the provider list is + // empty, and EPtoGenAI intentionally has no CPU name, so CPU clears the list without appending a provider. + if (ep_ != ExecutionProvider::kDefault) { try { oga_config->ClearProviders(); - std::string_view provider_str = EPUtils::EPtoGenAI(ep_); - oga_config->AppendProvider(provider_str.data()); + if (ep_ != ExecutionProvider::kCPU) { + std::string_view provider_str = EPUtils::EPtoGenAI(ep_); + oga_config->AppendProvider(provider_str.data()); + } // Disable CUDA graph for CUDA EP (matches C# behavior) if (ep_ == ExecutionProvider::kCUDA) { From a1d78543c3f84aae107189f59949258928680deb Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:06:59 -0700 Subject: [PATCH 5/5] Addressed the comments --- .../include/foundry_local/foundry_local_c.h | 6 +- sdk_v2/cpp/src/c_api.cc | 79 +------------------ sdk_v2/cpp/src/catalog/base_model_catalog.cc | 4 +- sdk_v2/cpp/src/catalog/base_model_catalog.h | 7 +- sdk_v2/cpp/src/catalog/local_model_catalog.cc | 4 +- sdk_v2/cpp/test/internal_api/c_api_test.cc | 14 ++-- 6 files changed, 21 insertions(+), 93 deletions(-) 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 743605353..f9d67616d 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -60,7 +60,7 @@ * Incremented with each release. * Used to request the API function table via FoundryLocalGetApi. * ----------------------------------------------------------------------- */ -#define FOUNDRY_LOCAL_API_VERSION 3 +#define FOUNDRY_LOCAL_API_VERSION 2 /* ----------------------------------------------------------------------- * Platform export macros (C version) @@ -1079,12 +1079,10 @@ struct flModelApi { FL_API_STATUS(Info_SetIntProperty, _In_ flModelInfo* info, _In_ const char* key, int64_t value); FL_API_STATUS(Info_SerializeToFile, _In_ const flModelInfo* info, _In_ const char* file_path); FL_API_STATUS(Info_DeserializeFromFile, _In_ const char* file_path, _Outptr_ flModelInfo** out_info); - - // End V2 /// Create a caller-owned deep copy. Release it with ReleaseModelInfo. FL_API_STATUS(Info_Clone, _In_ const flModelInfo* info, _Outptr_ flModelInfo** out_info); - // End V3 + // End V2 }; #ifdef __cplusplus diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index b7345533d..57910a421 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -1149,39 +1149,7 @@ static const flModelApi g_model_api_v1 = { Info_GetIntPropertyImpl, }; -static const flModelApi g_model_api_v2 = { - Model_GetInfoImpl, - Model_GetInputOutputInfoImpl, - Model_IsCachedImpl, - Model_GetPathImpl, - Model_DownloadImpl, - Model_IsLoadedImpl, - Model_LoadImpl, - Model_UnloadImpl, - Model_RemoveFromCacheImpl, - Model_GetVariantsImpl, - Model_SelectVariantImpl, - Info_GetIdImpl, - Info_GetNameImpl, - Info_GetVersionImpl, - Info_GetAliasImpl, - Info_GetUriImpl, - Info_GetDeviceTypeImpl, - Info_GetExecutionProviderImpl, - Info_GetTaskImpl, - Info_GetPromptTemplatesImpl, - Info_GetModelSettingsImpl, - Info_GetStringPropertyImpl, - Info_GetIntPropertyImpl, - ModelInfo_CreateImpl, - ModelInfo_ReleaseImpl, - Info_SetStringPropertyImpl, - Info_SetIntPropertyImpl, - Info_SerializeToFileImpl, - Info_DeserializeFromFileImpl, - }; - - static const flModelApi g_model_api = { +static const flModelApi g_model_api = { Model_GetInfoImpl, Model_GetInputOutputInfoImpl, Model_IsCachedImpl, @@ -2065,10 +2033,6 @@ static const flModelApi* FL_API_CALL GetModelApiImpl() FL_NO_EXCEPTION { return &g_model_api; } -static const flModelApi* FL_API_CALL GetModelApiV2Impl() FL_NO_EXCEPTION { - return &g_model_api_v2; -} - static const flModelApi* FL_API_CALL GetModelApiV1Impl() FL_NO_EXCEPTION { return &g_model_api_v1; } @@ -2121,40 +2085,6 @@ static const flApi g_api_v1 = { }; static const flApi g_api_v2 = { - Status_CreateImpl, - Status_ReleaseImpl, - Status_GetErrorCodeImpl, - Status_GetErrorMessageImpl, - Manager_CreateImpl, - Manager_ReleaseImpl, - Manager_GetCatalogImpl, - Manager_WebServiceStartImpl, - Manager_WebServiceUrlsImpl, - Manager_WebServiceStopImpl, - GetCatalogApiImpl, - GetConfigurationApiImpl, - GetItemApiImpl, - GetInferenceApiImpl, - GetModelApiV2Impl, - CreateKeyValuePairsImpl, - AddKeyValuePairImpl, - GetKeyValueImpl, - GetKeyValuePairsImpl, - RemoveKeyValuePairImpl, - KeyValuePairs_ReleaseImpl, - ModelList_ReleaseImpl, - ModelList_SizeImpl, - ModelList_GetAtImpl, - Manager_GetDiscoverableEpsImpl, - Manager_DownloadAndRegisterEpsImpl, - Manager_IsEpDownloadInProgressImpl, - Manager_ShutdownImpl, - Manager_IsShutdownRequestedImpl, - Manager_GetCatalogByTypeImpl, - Manager_GetCatalogByNameImpl, -}; - - static const flApi g_api_v3 = { Status_CreateImpl, Status_ReleaseImpl, Status_GetErrorCodeImpl, @@ -2186,7 +2116,7 @@ static const flApi g_api_v1 = { Manager_IsShutdownRequestedImpl, Manager_GetCatalogByTypeImpl, Manager_GetCatalogByNameImpl, - }; +}; // ======================================================================== // Exported symbols — the ONLY symbols the library exports @@ -2198,12 +2128,9 @@ FL_EXPORT const flApi* FL_API_CALL FoundryLocalGetApi(uint32_t version) FL_NO_EX if (version == 1) { return &g_api_v1; } - if (version == 2) { + if (version == 0 || version == 2) { return &g_api_v2; } - if (version == 0 || version == 3) { - return &g_api_v3; - } return nullptr; } diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.cc b/sdk_v2/cpp/src/catalog/base_model_catalog.cc index 3e688688a..9c8ecca1f 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.cc @@ -385,7 +385,7 @@ std::vector BaseModelCatalog::GetLoadedModels() const { return result; } -Model* BaseModelCatalog::AddModel(Model model) { +Model* BaseModelCatalog::AppendActiveModel(Model model) { EnsurePopulated(); std::lock_guard lock(mutex_); auto container = std::make_unique(Model::MakeContainer(std::move(model))); @@ -396,7 +396,7 @@ Model* BaseModelCatalog::AddModel(Model model) { return result; } -bool BaseModelCatalog::DeactivateModel(const std::string& alias_or_model_id) { +bool BaseModelCatalog::RetireModel(const std::string& alias_or_model_id) { EnsurePopulated(); std::lock_guard lock(mutex_); for (auto& stored : models_) { diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.h b/sdk_v2/cpp/src/catalog/base_model_catalog.h index c72857e50..2f24bd722 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.h @@ -50,8 +50,11 @@ class BaseModelCatalog : public ICatalog { protected: BaseModelCatalog(std::string name, CatalogType type, ILogger& logger); - Model* AddModel(Model model); - bool DeactivateModel(const std::string& alias_or_model_id); + /// Append a newly registered active model. Inactive tombstones are never revived. + Model* AppendActiveModel(Model model); + + /// Remove a model from catalog lookup while retaining its storage for pointer safety. + bool RetireModel(const std::string& alias_or_model_id); /// Derived classes implement this to fetch model variants from their source. /// Returns the full variant list. Base class handles caching and indexing. diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index dc30c7671..942116098 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -184,7 +184,7 @@ Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { } try { - return AddModel(CreateModel(registration)); + return AppendActiveModel(CreateModel(registration)); } catch (...) { std::lock_guard guard(registration_mutex_); FileLock file_lock(lock_path_); @@ -233,7 +233,7 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { SaveRegistrations(registrations); } - DeactivateModel(alias_or_model_id); + RetireModel(alias_or_model_id); model->CancelUnregister(); unregister_lock_held = false; } catch (...) { 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 32a654b85..28c687de5 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -33,18 +33,18 @@ TEST(CApiTest, GetApiReturnsNullForFutureVersion) { EXPECT_EQ(api, nullptr); } -TEST(CApiTest, ModelInfoCloneIsAvailableOnlyInV3) { +TEST(CApiTest, ModelInfoCloneIsAvailableInV2) { + const flApi* v1 = FoundryLocalGetApi(1); const flApi* v2 = FoundryLocalGetApi(2); - const flApi* v3 = FoundryLocalGetApi(3); + ASSERT_NE(v1, nullptr); ASSERT_NE(v2, nullptr); - ASSERT_NE(v3, nullptr); + const flModelApi* model_v1 = v1->GetModelApi(); const flModelApi* model_v2 = v2->GetModelApi(); - const flModelApi* model_v3 = v3->GetModelApi(); + ASSERT_NE(model_v1, nullptr); ASSERT_NE(model_v2, nullptr); - ASSERT_NE(model_v3, nullptr); - EXPECT_EQ(model_v2->Info_Clone, nullptr); - EXPECT_NE(model_v3->Info_Clone, nullptr); + EXPECT_EQ(model_v1->Info_Clone, nullptr); + EXPECT_NE(model_v2->Info_Clone, nullptr); } TEST(CApiTest, VersionReturnsNonNull) {