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 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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) { From 398f702e4b2940505b50eb8b8925f7ab339311a6 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:03:38 -0700 Subject: [PATCH 06/17] Added byom e2e test --- sdk_v2/cpp/test/CMakeLists.txt | 1 + .../sdk_api/bring_your_own_model_e2e_test.cc | 168 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index 49a7bb40f..43c6214de 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -148,6 +148,7 @@ add_executable(sdk_integration_tests sdk_api/embeddings_test.cc sdk_api/ep_detection_test.cc sdk_api/chat_session_test.cc + sdk_api/bring_your_own_model_e2e_test.cc sdk_api/model_test.cc sdk_api/model_endpoints_test.cc sdk_api/chat_completions_test.cc diff --git a/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc b/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc new file mode 100644 index 000000000..cb988ea02 --- /dev/null +++ b/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// End-to-end coverage for creating missing local-model metadata during registration and then running inference. + +#include "model_fixture.h" + +#include "internal_api/test_model_cache.h" +#include "utils/temp_path.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* kByomSourceModelEnvironmentVariable = "FOUNDRY_LOCAL_BYOM_TEST_MODEL_PATH"; +constexpr const char* kByomChatModelAlias = "qwen2.5-coder-0.5b-instruct-generic-cpu-3"; + +std::optional FindGenAiModelDirectory(const fs::path& model_path) { + if (fs::exists(model_path / "genai_config.json")) { + return fs::canonical(model_path); + } + + if (!fs::is_directory(model_path)) { + return std::nullopt; + } + + for (const auto& entry : fs::directory_iterator(model_path)) { + if (entry.is_directory() && fs::exists(entry.path() / "genai_config.json")) { + return fs::canonical(entry.path()); + } + } + + // Shared test data uses /Microsoft//. Also accept / as the override so the + // documented path remains useful even when that directory is an empty catalog placeholder. + const auto publisher_model_path = model_path.parent_path() / "Microsoft" / model_path.filename(); + if (publisher_model_path != model_path && fs::is_directory(publisher_model_path)) { + return FindGenAiModelDirectory(publisher_model_path); + } + + return std::nullopt; +} + +std::optional GetByomSourceModelPath() { + const auto override_path = fl::test::SafeGetEnv(kByomSourceModelEnvironmentVariable); + if (!override_path.empty()) { + return FindGenAiModelDirectory(override_path); + } + + try { + return fl::test::GetTestModelPath(kByomChatModelAlias); + } catch (const std::exception&) { + return std::nullopt; + } +} + +void StageModelWithoutMetadata(const fs::path& source, const fs::path& destination) { + for (const auto& entry : fs::recursive_directory_iterator(source)) { + const auto relative_path = fs::relative(entry.path(), source); + if (relative_path.filename() == "inference_model.json" || relative_path.filename() == "model_metadata.yml") { + continue; + } + + const auto destination_path = destination / relative_path; + if (entry.is_directory()) { + fs::create_directories(destination_path); + continue; + } + if (!entry.is_regular_file()) { + continue; + } + + fs::create_directories(destination_path.parent_path()); + std::error_code hard_link_error; + fs::create_hard_link(entry.path(), destination_path, hard_link_error); + if (hard_link_error) { + fs::copy_file(entry.path(), destination_path, fs::copy_options::overwrite_existing); + } + } +} + +class LocalRegistrationGuard { + public: + LocalRegistrationGuard(foundry_local::ICatalog& catalog, std::unique_ptr model, + std::string alias) + : catalog_(catalog), model_(std::move(model)), alias_(std::move(alias)) {} + + ~LocalRegistrationGuard() { + try { + if (model_ && model_->IsLoaded()) { + model_->Unload(); + } + } catch (...) { + } + + model_.reset(); + try { + catalog_.UnregisterModel(alias_); + } catch (...) { + } + } + + foundry_local::IModel& model() { return *model_; } + + private: + foundry_local::ICatalog& catalog_; + std::unique_ptr model_; + std::string alias_; +}; + +} // namespace + +TEST(ByomE2eTest, RegisterModelCreatesMissingMetadataAndRunsChatInference) { + using namespace foundry_local; + + const auto source_model_path = GetByomSourceModelPath(); + if (!source_model_path) { + GTEST_SKIP() << "BYOM source model not found. Set " << kByomSourceModelEnvironmentVariable + << " or stage " << kByomChatModelAlias << " under FOUNDRY_TEST_DATA_DIR/Microsoft."; + } + + auto temp_root = fl::test::TempPath::CreateTempDir("fl_byom_e2e_"); + const auto staged_model_path = temp_root.path() / "model"; + fs::create_directories(staged_model_path); + StageModelWithoutMetadata(*source_model_path, staged_model_path); + + ASSERT_TRUE(fs::exists(staged_model_path / "genai_config.json")); + ASSERT_FALSE(fs::exists(staged_model_path / "inference_model.json")); + ASSERT_FALSE(fs::exists(staged_model_path / "model_metadata.yml")); + + auto& local_catalog = SharedTestEnv::Get().manager()->GetCatalog(CatalogType::Local); + const auto registration_alias = temp_root.path().filename().string(); + ModelInfo registration; + registration.SetStringProperty(FOUNDRY_LOCAL_REG_MODEL_PATH, staged_model_path.string().c_str()); + registration.SetStringProperty(FOUNDRY_LOCAL_REG_ALIAS, registration_alias.c_str()); + + LocalRegistrationGuard registered(local_catalog, local_catalog.RegisterModel(registration), registration_alias); + auto& model = registered.model(); + + EXPECT_EQ(model.GetInfo().Alias(), registration_alias); + EXPECT_EQ(model.GetInfo().Task(), "chat-completion"); + EXPECT_TRUE(model.IsCached()); + EXPECT_FALSE(model.IsLoaded()); + EXPECT_TRUE(fs::exists(staged_model_path / "model_metadata.yml")); + EXPECT_FALSE(fs::exists(staged_model_path / "inference_model.json")); + + model.Load(); + ASSERT_TRUE(model.IsLoaded()); + + ChatSession session(model); + Request request{ + SystemMessage("You are a concise math assistant."), + UserMessage("What is 2+2? Answer with only the number."), + }; + RequestOptions options; + options.search.temperature = 0.0f; + options.search.max_output_tokens = 16; + request.SetOptions(options); + + Response response = session.ProcessRequest(request); + + EXPECT_EQ(response.GetFinishReason(), FOUNDRY_LOCAL_FINISH_STOP); + const auto response_text = CollectResponseText(response); + EXPECT_FALSE(response_text.empty()); + EXPECT_NE(response_text.find('4'), std::string::npos) << "Unexpected response: " << response_text; +} \ No newline at end of file From 75314ca67d6b607f9ce1b8795b48876806107ae7 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:23:51 -0700 Subject: [PATCH 07/17] Guard config-derived EPs for BYOM model loads --- .../cpp/src/inferencing/model_load_manager.cc | 44 +++++- .../internal_api/model_load_manager_test.cc | 127 +++++++++++++++++- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/sdk_v2/cpp/src/inferencing/model_load_manager.cc b/sdk_v2/cpp/src/inferencing/model_load_manager.cc index a6e2defa8..a13c7441c 100644 --- a/sdk_v2/cpp/src/inferencing/model_load_manager.cc +++ b/sdk_v2/cpp/src/inferencing/model_load_manager.cc @@ -47,6 +47,36 @@ std::string_view RequiredEpForModelId(std::string_view model_id) { return {}; } +/// Returns whether the provider is DirectML, which is supplied by WinML rather than a downloadable EP bootstrapper. +bool IsDmlProvider(std::string_view provider) { + return provider == "dml" || provider == "DML" || provider == "DmlExecutionProvider" || + provider == "DMLExecutionProvider"; +} + +/// Returns the required EP registration name for a provider declared in genai_config.json. +std::string RequiredEpForConfigProvider(std::string_view provider) { + if (provider.empty() || IsDmlProvider(provider)) { + return {}; + } + + auto ep = EPUtils::StringtoEP(provider); + if (provider == "WebGpuExecutionProvider") { + ep = ExecutionProvider::kWebGPU; + } + + if (ep == ExecutionProvider::kCPU) { + return {}; + } + if (ep != ExecutionProvider::kUnknown) { + return std::string(EPUtils::EPtoRegistrationName(ep)); + } + + // Configs may name WinML/OGA providers that are intentionally outside the public override enum, such as + // MIGraphXExecutionProvider and RyzenAILightExecutionProvider. Canonical registration names can still be guarded. + constexpr std::string_view suffix = "ExecutionProvider"; + return provider.ends_with(suffix) ? std::string(provider) : std::string{}; +} + } // namespace // --------------------------------------------------------------------------- @@ -118,6 +148,10 @@ ModelLoadManager::LoadResult ModelLoadManager::LoadModel(std::string_view model_ // Determine execution provider auto resolved_ep = ep_override; + if (resolved_ep == ExecutionProvider::kUnknown) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "unknown execution provider override"); + } + if (resolved_ep == ExecutionProvider::kDefault) { // Auto-select EP for generic-gpu models: DML models are compatible with // CUDA and WebGPU, so try those in order when available. @@ -132,11 +166,15 @@ ModelLoadManager::LoadResult ModelLoadManager::LoadModel(std::string_view model_ } } - std::string_view required_ep; - if (resolved_ep != ExecutionProvider::kDefault && resolved_ep != ExecutionProvider::kCPU) { + std::string required_ep; + if (resolved_ep == ExecutionProvider::kCPU) { + // Explicit CPU overrides both the provider in genai_config.json and model-ID hints. + } else if (resolved_ep != ExecutionProvider::kDefault) { required_ep = EPUtils::EPtoRegistrationName(resolved_ep); } else { - required_ep = RequiredEpForModelId(id_str); + const auto config_provider = genai_config.DefaultProvider(); + required_ep = config_provider.empty() ? std::string(RequiredEpForModelId(id_str)) + : RequiredEpForConfigProvider(config_provider); } // OGA can crash or hang if a model is loaded with an unregistered EP. diff --git a/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc b/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc index c17817b9c..4ed5e1ea7 100644 --- a/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc @@ -41,19 +41,31 @@ class CpuOnlyDetector : public fl::IEpDetector { std::map> GetAvailableDevicesToEPs() const override { return {{"CPU", {"CPUExecutionProvider"}}}; } + + bool PrepareForModelLoad(std::string_view ep_name) override { + prepared_ep = ep_name; + return true; + } + + std::string prepared_ep; }; /// Creates a minimal model directory with a dummy genai_config.json. /// Cleans up on destruction. class TempModelDir { public: - TempModelDir(const std::string& model_name) { + explicit TempModelDir(const std::string& model_name, const std::string& provider = "") { path_ = (std::filesystem::temp_directory_path() / ("fl_test_" + model_name)).string(); std::filesystem::create_directories(path_); // Write a minimal genai_config.json std::ofstream config(std::filesystem::path(path_) / "genai_config.json"); - config << R"({"model": {"type": "phi3"}})"; + if (provider.empty()) { + config << R"({"model": {"type": "phi3"}})"; + } else { + config << R"({"model":{"type":"phi3","decoder":{"session_options":{"provider_options":[{")" + << provider << R"(":{}}]}}}})"; + } } ~TempModelDir() { @@ -132,6 +144,117 @@ TEST(ModelLoadManagerTest, LoadCudaGpuModel_CudaNotAvailable_ErrorMessage) { } } +TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaNotAvailable_Throws) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("alias-cuda-config", "cuda"); + + try { + mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + FAIL() << "Expected exception"; + } catch (const fl::Exception& e) { + EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + EXPECT_NE(std::string(e.what()).find("CUDAExecutionProvider"), std::string::npos); + } +} + +TEST(ModelLoadManagerTest, LoadAliasWithWebGpuConfig_WebGpuNotAvailable_Throws) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("alias-webgpu-config", "WebGPU"); + + try { + mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + FAIL() << "Expected exception"; + } catch (const fl::Exception& e) { + EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + EXPECT_NE(std::string(e.what()).find("WebGpuExecutionProvider"), std::string::npos); + } +} + +TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaAvailable_PreparesCuda) { + GpuEpDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("alias-cuda-available", "cuda"); + + try { + mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + } catch (const fl::Exception& e) { + EXPECT_NE(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + + EXPECT_EQ(ep.prepared_ep, "CUDAExecutionProvider"); +} + +TEST(ModelLoadManagerTest, LoadAliasWithCanonicalWinMlProvider_NotAvailable_Throws) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("alias-migraphx-config", "MIGraphXExecutionProvider"); + + try { + mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + FAIL() << "Expected exception"; + } catch (const fl::Exception& e) { + EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + EXPECT_NE(std::string(e.what()).find("MIGraphXExecutionProvider"), std::string::npos); + } +} + +TEST(ModelLoadManagerTest, LoadAliasWithDmlConfig_DoesNotRequireDownloadableEp) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("alias-dml-config", "dml"); + + try { + mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + } catch (const fl::Exception& e) { + EXPECT_NE(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + + EXPECT_TRUE(ep.prepared_ep.empty()); +} + +TEST(ModelLoadManagerTest, LoadWithUnknownOverride_ThrowsInvalidArgument) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("unknown-override"); + + try { + mgr.LoadModel(dir.path(), "local/arbitrary-alias:0", fl::ExecutionProvider::kUnknown); + FAIL() << "Expected exception"; + } catch (const fl::Exception& e) { + EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + } +} + +TEST(ModelLoadManagerTest, LoadWithCpuOverride_IgnoresCudaConfigAndModelId) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + + TempModelDir dir("explicit-cpu", "cuda"); + + try { + mgr.LoadModel(dir.path(), "local/alias-cuda-gpu:0", fl::ExecutionProvider::kCPU); + } catch (const fl::Exception& e) { + EXPECT_NE(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + + EXPECT_TRUE(ep.prepared_ep.empty()); +} + TEST(ModelLoadManagerTest, LoadOpenVinoNpuModel_NotAvailable_Throws) { CpuOnlyDetector ep; fl::StderrLogger logger; From 37bfd13af3e7f4bea9660ac1c9082d8bf72fbcf6 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:23:22 -0700 Subject: [PATCH 08/17] Remove redundant GetLocalModels catalog API --- .../include/foundry_local/foundry_local_c.h | 2 -- .../include/foundry_local/foundry_local_cpp.h | 4 ---- .../foundry_local/foundry_local_cpp.inline.h | 6 ------ sdk_v2/cpp/src/c_api.cc | 18 ------------------ sdk_v2/cpp/src/catalog.h | 2 -- sdk_v2/cpp/src/catalog/local_model_catalog.cc | 4 ---- sdk_v2/cpp/src/catalog/local_model_catalog.h | 1 - .../internal_api/local_model_catalog_test.cc | 1 - 8 files changed, 38 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 f9d67616d..fd695cd55 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -999,8 +999,6 @@ struct flCatalogApi { _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 }; 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 4ba7eb1fe..8d5332c81 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -812,9 +812,6 @@ class ICatalog { 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); - } }; // =========================================================================== @@ -842,7 +839,6 @@ class Catalog final : public ICatalog { 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_; 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 f0b46cbb4..99403b536 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 @@ -714,12 +714,6 @@ 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 57910a421..3bfd8d315 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -776,23 +776,6 @@ FL_API_STATUS_IMPL(Catalog_UnregisterModelImpl, flCatalog* catalog, const char* 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, @@ -815,7 +798,6 @@ static const flCatalogApi g_catalog_api = { Catalog_GetModelVersionsImpl, Catalog_RegisterModelImpl, Catalog_UnregisterModelImpl, - Catalog_GetLocalModelsImpl, }; // ======================================================================== diff --git a/sdk_v2/cpp/src/catalog.h b/sdk_v2/cpp/src/catalog.h index 3b54c5b94..b6367f8bb 100644 --- a/sdk_v2/cpp/src/catalog.h +++ b/sdk_v2/cpp/src/catalog.h @@ -76,8 +76,6 @@ class ICatalog { 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/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index 942116098..07add6cfb 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -244,10 +244,6 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { } } -std::vector LocalModelCatalog::GetLocalModels() const { - return ListModels(); -} - ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const ModelInfo* previous, const std::string& model_path, const std::string& alias, bool* assets_inspected) const { diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.h b/sdk_v2/cpp/src/catalog/local_model_catalog.h index 78e73ca6e..935f8e482 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.h @@ -21,7 +21,6 @@ class LocalModelCatalog final : public BaseModelCatalog { 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; 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 745464332..c6b839d26 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 @@ -56,7 +56,6 @@ TEST_F(LocalModelCatalogTest, RegisterResolvesMetadataListsAndWritesFiles) { 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")); const auto index_path = root_.path() / "appdata" / "catalogs" / "local" / "local_models.json"; ASSERT_TRUE(std::filesystem::exists(index_path)); From db04e302922be9bd8d15b76d25999ca53da3e617 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:51 -0700 Subject: [PATCH 09/17] Removed the private catalog --- sdk_v2/cpp/include/foundry_local/foundry_local_c.h | 1 - sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h | 1 - sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h | 5 ----- sdk_v2/cpp/src/c_api.cc | 2 -- sdk_v2/cpp/src/catalog.h | 1 - sdk_v2/cpp/src/manager.cc | 2 -- 6 files changed, 12 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 821d00e18..f11635009 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -205,7 +205,6 @@ typedef enum 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. 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 1e3c3dc61..2bcf143b1 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -304,7 +304,6 @@ struct Runtime { enum class CatalogType { Public = FOUNDRY_LOCAL_CATALOG_PUBLIC, Local = FOUNDRY_LOCAL_CATALOG_LOCAL, - Private = FOUNDRY_LOCAL_CATALOG_PRIVATE, }; // =========================================================================== 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 4d9620940..69be6c7b9 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 @@ -214,11 +214,6 @@ inline ICatalog& Manager::GetCatalog(CatalogType type) const { 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)); diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index e1d2c98b9..e24b12e5e 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -370,8 +370,6 @@ FL_API_STATUS_IMPL(Manager_GetCatalogByTypeImpl, const flManager* manager, flCat 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"); } diff --git a/sdk_v2/cpp/src/catalog.h b/sdk_v2/cpp/src/catalog.h index b6367f8bb..63b581df3 100644 --- a/sdk_v2/cpp/src/catalog.h +++ b/sdk_v2/cpp/src/catalog.h @@ -15,7 +15,6 @@ namespace fl { enum class CatalogType { kPublic, kLocal, - kPrivate, }; /// Abstract catalog interface for querying available models. diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 5a2742986..6bfbbedf1 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -454,8 +454,6 @@ ICatalog& Manager::GetCatalog(CatalogType type) { 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"); } From fe1a713c7597c2691d75438dd206862ca8000974 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:31:33 -0700 Subject: [PATCH 10/17] Address high-confidence BYOM review feedback --- .../include/foundry_local/foundry_local_c.h | 6 - .../include/foundry_local/foundry_local_cpp.h | 15 +- .../foundry_local/foundry_local_cpp.inline.h | 44 +- sdk_v2/cpp/src/c_api.cc | 155 +------ sdk_v2/cpp/src/catalog/base_model_catalog.cc | 25 +- sdk_v2/cpp/src/catalog/base_model_catalog.h | 3 + sdk_v2/cpp/src/catalog/local_model_catalog.cc | 386 +++++------------ sdk_v2/cpp/src/catalog/local_model_catalog.h | 13 +- sdk_v2/cpp/src/manager.cc | 24 +- sdk_v2/cpp/src/manager.h | 5 +- sdk_v2/cpp/src/model.cc | 130 ++---- sdk_v2/cpp/src/model.h | 16 +- sdk_v2/cpp/src/model_info.cc | 37 -- sdk_v2/cpp/src/model_info.h | 3 - sdk_v2/cpp/test/internal_api/c_api_test.cc | 126 +++--- .../internal_api/local_model_catalog_test.cc | 401 +++++------------- .../internal_api/model_info_accessors_test.cc | 49 --- .../cpp/test/internal_api/model_info_test.cc | 19 - .../sdk_api/bring_your_own_model_e2e_test.cc | 12 +- 19 files changed, 356 insertions(+), 1113 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 f11635009..f3cef2199 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -729,8 +729,6 @@ typedef struct flApi { // 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 */ @@ -1077,10 +1075,6 @@ struct flModelApi { 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); - /// Create a caller-owned deep copy. Release it with ReleaseModelInfo. - FL_API_STATUS(Info_Clone, _In_ const flModelInfo* info, _Outptr_ flModelInfo** out_info); // End V2 }; 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 2bcf143b1..71261bf40 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -311,22 +311,21 @@ enum class CatalogType { // =========================================================================== /// 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. +/// Construction from `const flModelInfo&` creates a non-owning read-only view tied to its Model. class ModelInfo { public: 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(const ModelInfo&) = delete; + ModelInfo& operator=(const ModelInfo&) = delete; 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); + + const flModelInfo* native_handle() const noexcept { return handle_.get(); } // Core identity. std::string_view Id() const noexcept; @@ -396,11 +395,8 @@ class ModelInfo { std::optional Capabilities() const noexcept; private: - explicit ModelInfo(flModelInfo& info); static std::string_view safe(const char* s) noexcept { return s ? s : ""; } detail::Base handle_; - - friend class Catalog; }; // =========================================================================== @@ -870,7 +866,6 @@ 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(); 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 69be6c7b9..a9f90e553 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 @@ -222,16 +222,6 @@ inline ICatalog& Manager::GetCatalog(CatalogType type) const { 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())); } @@ -323,26 +313,8 @@ inline ModelInfo::ModelInfo() 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(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; -} + }(), + 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)); @@ -354,16 +326,6 @@ inline ModelInfo& ModelInfo::SetIntProperty(const char* key, int64_t 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(handle_.get())); } @@ -697,7 +659,7 @@ inline ModelList Catalog::GetModelVersions(const std::string& model_alias, 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)); + Check(detail::catalog_api()->RegisterModel(handle_.get_mutable(), model_info.native_handle(), &model)); return std::make_unique(*model); } diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index e24b12e5e..6cce5994d 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -376,20 +376,6 @@ FL_API_STATUS_IMPL(Manager_GetCatalogByTypeImpl, const flManager* manager, flCat 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 -} - FL_API_STATUS_IMPL(Manager_WebServiceStartImpl, flManager* manager) { API_IMPL_BEGIN if (!manager) { @@ -773,17 +759,6 @@ FL_API_STATUS_IMPL(Catalog_UnregisterModelImpl, flCatalog* catalog, const char* 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, @@ -1066,68 +1041,6 @@ FL_API_STATUS_IMPL(Info_SetIntPropertyImpl, flModelInfo* info, const char* key, 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 -} - -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, - 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, @@ -1156,9 +1069,6 @@ static const flModelApi g_model_api = { ModelInfo_ReleaseImpl, Info_SetStringPropertyImpl, Info_SetIntPropertyImpl, - Info_SerializeToFileImpl, - Info_DeserializeFromFileImpl, - Info_CloneImpl, }; // ======================================================================== @@ -1992,10 +1902,6 @@ 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; } @@ -2012,58 +1918,7 @@ 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) -// ======================================================================== - -static const flApi g_api_v1 = { - /* Status */ - Status_CreateImpl, - Status_ReleaseImpl, - Status_GetErrorCodeImpl, - Status_GetErrorMessageImpl, - - /* Manager lifecycle */ - Manager_CreateImpl, - Manager_ReleaseImpl, - Manager_GetCatalogImpl, - Manager_WebServiceStartImpl, - Manager_WebServiceUrlsImpl, - Manager_WebServiceStopImpl, - - /* Sub-API accessors */ - GetCatalogApiV1Impl, - GetConfigurationApiImpl, - GetItemApiImpl, - GetInferenceApiImpl, - GetModelApiV1Impl, - - /* KeyValuePairs */ - CreateKeyValuePairsImpl, - AddKeyValuePairImpl, - GetKeyValueImpl, - GetKeyValuePairsImpl, - RemoveKeyValuePairImpl, - KeyValuePairs_ReleaseImpl, - - /* ModelList */ - ModelList_ReleaseImpl, - ModelList_SizeImpl, - ModelList_GetAtImpl, - - /* EP detection */ - Manager_GetDiscoverableEpsImpl, - Manager_DownloadAndRegisterEpsImpl, - Manager_IsEpDownloadInProgressImpl, - Manager_ShutdownImpl, - Manager_IsShutdownRequestedImpl, - }; - - static const flApi g_api_v2 = { +static const flApi g_api = { Status_CreateImpl, Status_ReleaseImpl, Status_GetErrorCodeImpl, @@ -2094,7 +1949,6 @@ static const flApi g_api_v1 = { Manager_ShutdownImpl, Manager_IsShutdownRequestedImpl, Manager_GetCatalogByTypeImpl, - Manager_GetCatalogByNameImpl, }; // ======================================================================== @@ -2104,11 +1958,8 @@ static const flApi g_api_v1 = { extern "C" { FL_EXPORT const flApi* FL_API_CALL FoundryLocalGetApi(uint32_t version) FL_NO_EXCEPTION { - if (version == 1) { - return &g_api_v1; - } - if (version == 0 || version == 2) { - return &g_api_v2; + if (version <= FOUNDRY_LOCAL_API_VERSION) { + return &g_api; } 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 9c8ecca1f..ffeab7d1b 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.cc @@ -16,9 +16,9 @@ namespace fl { BaseModelCatalog::BaseModelCatalog(std::string name, ILogger& logger) - : BaseModelCatalog(std::move(name), CatalogType::kPublic, 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) {} + : name_(std::move(name)), type_(type), logger_(logger) {} BaseModelCatalog::~BaseModelCatalog() = default; void BaseModelCatalog::PopulateModels(std::vector variants) const { @@ -50,9 +50,7 @@ void BaseModelCatalog::PopulateModels(std::vector variants) const { } // On refresh: merge new models into stable storage. Existing models keep their addresses. - // New aliases are appended. Existing aliases are left unchanged (their Model* stays valid). if (populated_) { - // Build a set of existing aliases for fast lookup. std::unordered_map existing_aliases; for (auto& stored : models_) { if (stored.active) { @@ -60,6 +58,21 @@ void BaseModelCatalog::PopulateModels(std::vector variants) const { } } + if (IsAuthoritativeSnapshot()) { + for (auto& stored : models_) { + if (!stored.active) { + continue; + } + + const auto incoming = alias_to_model.find(stored.model->Alias()); + if (incoming == alias_to_model.end() || incoming->second.RuntimeId() != stored.model->RuntimeId()) { + stored.active = false; + stored.model->Deactivate(); + existing_aliases.erase(stored.model->Alias()); + } + } + } + size_t new_count = 0; for (auto& [alias, model] : alias_to_model) { if (!existing_aliases.contains(alias)) { @@ -238,8 +251,8 @@ void BaseModelCatalog::EnsurePopulated(bool allow_refresh) const { // not worth the complexity to optimise.) std::lock_guard lock(mutex_); - bool needs_refresh = allow_refresh && - std::chrono::steady_clock::now() >= next_refresh_at_; + const bool needs_refresh = IsAuthoritativeSnapshot() || + (allow_refresh && std::chrono::steady_clock::now() >= next_refresh_at_); if (populated_ && !needs_refresh) { return; diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.h b/sdk_v2/cpp/src/catalog/base_model_catalog.h index 2f24bd722..292777557 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.h @@ -82,6 +82,9 @@ class BaseModelCatalog : public ICatalog { return {}; } + /// Authoritative catalogs reconcile removals and replacements from every fetched snapshot. + virtual bool IsAuthoritativeSnapshot() const { return false; } + private: /// Lookup indices into the stable models_ storage. /// Rebuilt on refresh. Does not own any Model instances. diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index 07add6cfb..a697b5e78 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -9,15 +9,15 @@ #include #include -#include #include +#include #include #include #include #include #include #include -#include +#include #ifdef _WIN32 #define WIN32_LEAN_AND_MEAN @@ -29,91 +29,40 @@ namespace fl { namespace { constexpr const char* kRegistrationIdProperty = "_local_registration_id"; +const std::regex kAliasPattern("[A-Za-z0-9][A-Za-z0-9._-]*"); +const std::unordered_set kSupportedTasks = { + "automatic-speech-recognition", + "chat-completion", + "embeddings", + "vision-language-chat", +}; std::string UtcTimestamp(int64_t unix_time) { - std::time_t value = static_cast(unix_time); + const auto 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'; - } + return std::any_of(path.begin(), path.end(), [](const auto& component) { return component == ".."; }); } nlohmann::json RegistrationToJson(const LocalModelCatalog::Registration& registration) { return { {"alias", registration.info.alias}, + {"model_id", registration.info.model_id}, {"model_path", registration.model_path}, - {"registered_at", registration.info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, {})}, + {"registered_at", + registration.info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, std::string{})}, {"properties", ModelInfoToPropertyBagJson(registration.info)}, - {"metadata_prepared", registration.metadata_prepared}, }; } @@ -128,75 +77,96 @@ LocalModelCatalog::LocalModelCatalog(std::filesystem::path app_data_dir, ModelFa logger_(logger) {} std::vector LocalModelCatalog::FetchModels() const { + std::lock_guard guard(registration_mutex_); FileLock file_lock(lock_path_); + const auto registrations = LoadRegistrations(); + std::vector models; - for (const auto& registration : LoadRegistrations()) { + models.reserve(registrations.size()); + for (const auto& registration : registrations) { 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"); } + + const auto* alias_value = model_info.GetPropertyStr(FOUNDRY_LOCAL_REG_ALIAS); 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._-]*"))) { + + if (!std::regex_match(*alias_value, kAliasPattern)) { 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); + const 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(); + std::error_code ec; + if (!std::filesystem::is_directory(supplied_path, ec)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_path must be an existing directory"); + } + + const auto model_path = std::filesystem::absolute(supplied_path).lexically_normal(); + const auto config_path = model_path / "genai_config.json"; + if (!std::filesystem::is_regular_file(config_path, ec)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_path must contain a regular genai_config.json file"); + } + + GenAIConfig::LoadFromFile(config_path.string()); + + const auto* task = model_info.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR); + if (!task || task->empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "task is required"); + } + if (!kSupportedTasks.contains(*task)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "unsupported task: " + *task); + } + 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"); - } + const auto duplicate = std::find_if(registrations.begin(), registrations.end(), [&](const auto& existing) { + return existing.info.alias == *alias_value; + }); + if (duplicate != registrations.end()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + "a model with alias '" + *alias_value + "' is already registered"); } - bool assets_inspected = false; - registration = {ResolveMetadata(model_info, nullptr, model_path, *alias_value, &assets_inspected), model_path, - assets_inspected}; + registration = {ResolveMetadata(model_info, model_path.string(), *alias_value), model_path.string()}; auto registration_id = registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); - while (std::any_of(registrations.begin(), registrations.end(), [&](const Registration& existing) { + while (std::any_of(registrations.begin(), registrations.end(), [&](const auto& 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); } - try { - return AppendActiveModel(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; + ListModels(); + auto* model = GetModelVariant(registration.info.model_id); + const auto runtime_id = + "local/" + registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); + if (!model || model->RuntimeId() != runtime_id) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "registered model was not available after catalog refresh"); } + + return model; } void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { @@ -204,6 +174,7 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias_or_model_id must not be empty"); } + ListModels(); auto* model = GetModel(alias_or_model_id); if (!model) { model = GetModelVariant(alias_or_model_id); @@ -211,8 +182,8 @@ void LocalModelCatalog::UnregisterModel(const std::string& 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"); @@ -222,7 +193,7 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { 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) { + const auto end = std::remove_if(registrations.begin(), registrations.end(), [&](const auto& registration) { return registration.info.alias == alias_or_model_id || registration.info.model_id == alias_or_model_id; }); if (end == registrations.end()) { @@ -233,25 +204,17 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { SaveRegistrations(registrations); } - RetireModel(alias_or_model_id); + ListModels(); model->CancelUnregister(); - unregister_lock_held = false; } catch (...) { - if (unregister_lock_held) { - model->CancelUnregister(); - } + model->CancelUnregister(); throw; } } -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()); +ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const std::string& model_path, + const std::string& alias) const { + auto resolved = metadata; 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"); @@ -271,69 +234,30 @@ ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const Mo 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"); - 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)); - } - 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 (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. - 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()); - } - 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"); - } + const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, UtcTimestamp(now)); + const auto registration_id = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + SetModelInfoStringProperty(resolved, kRegistrationIdProperty, std::to_string(registration_id)); + return resolved; } std::vector LocalModelCatalog::LoadRegistrations() const { - std::vector registrations; std::ifstream stream(index_path_, std::ios::binary); if (!stream) { - return registrations; + return {}; } + std::vector registrations; try { nlohmann::json root; stream >> root; - if (!root.is_object() || root.value("version", 0) != 1 || !root.contains("models") || !root["models"].is_array()) { + 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; + return {}; } for (const auto& item : root["models"]) { @@ -342,42 +266,38 @@ 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._-]*"))) { + if (!registration_id || registration_id->empty() || !alias || !std::regex_match(*alias, kAliasPattern)) { 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); + info.uri.clear(); 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 || - entry.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == *registration_id; + + const auto duplicate = std::find_if(registrations.begin(), registrations.end(), [&](const auto& existing) { + return existing.info.alias == info.alias || existing.info.model_id == info.model_id || + existing.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == *registration_id; }); if (duplicate == registrations.end()) { - registrations.push_back( - {std::move(info), model_path.string(), item.value("metadata_prepared", false)}); + 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()); @@ -386,38 +306,8 @@ std::vector LocalModelCatalog::LoadRegistration } catch (const std::exception& ex) { logger_.Log(LogLevel::Warning, std::string("Ignoring unreadable local model registration index: ") + ex.what()); } - 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; + return registrations; } void LocalModelCatalog::SaveRegistrations(const std::vector& registrations) const { @@ -426,6 +316,7 @@ void LocalModelCatalog::SaveRegistrations(const std::vector& regis 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"; { @@ -433,11 +324,13 @@ void LocalModelCatalog::SaveRegistrations(const std::vector& regis 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)) { @@ -454,92 +347,9 @@ void LocalModelCatalog::SaveRegistrations(const std::vector& regis #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_id]() { return PrepareRegistrationMetadata(registration_id); }); + return model_factory_(registration.info, registration.model_path, "local/" + 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 935f8e482..f83829c11 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.h @@ -7,15 +7,13 @@ #include #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()>)>; + using ModelFactory = std::function; LocalModelCatalog(std::filesystem::path app_data_dir, ModelFactory model_factory, ILogger& logger); @@ -25,20 +23,17 @@ class LocalModelCatalog final : public BaseModelCatalog { struct Registration { ModelInfo info; std::string model_path; - bool metadata_prepared = false; }; protected: std::vector FetchModels() const override; + bool IsAuthoritativeSnapshot() const override { return true; } private: - 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; + ModelInfo ResolveMetadata(const ModelInfo& metadata, 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_; diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 6bfbbedf1..f8aaf125c 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -328,13 +328,10 @@ Manager::Manager(const Configuration& config) : config_(config) { 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)); + [this](ModelInfo info, std::string local_path, std::string runtime_model_id) { + return CreateLocalModel(std::move(info), std::move(local_path), std::move(runtime_model_id)); }, *logger_); - local_catalog_->ListModels(); } Manager::~Manager() { @@ -459,16 +456,6 @@ ICatalog& Manager::GetCatalog(CatalogType 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() { if (web_service_running_) { FL_LOG_AND_THROW(*logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "web service is already running"); @@ -580,12 +567,9 @@ Model Manager::CreateModel(ModelInfo info, std::string local_path) { return Model::FromModelInfo(std::move(info), std::move(local_path), *download_manager_, *model_load_manager_); } -Model Manager::CreateLocalModel(ModelInfo info, std::string local_path, - std::function unregister_callback, - std::function()> prepare_callback) { +Model Manager::CreateLocalModel(ModelInfo info, std::string local_path, std::string runtime_model_id) { return Model::FromLocalRegistration(std::move(info), std::move(local_path), *download_manager_, - *model_load_manager_, std::move(unregister_callback), - std::move(prepare_callback)); + *model_load_manager_, std::move(runtime_model_id)); } DownloadManager& Manager::GetDownloadManager() { return *download_manager_; } diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index 9b744d558..eccfc2aaa 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -53,7 +53,6 @@ class Manager { /// (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; @@ -157,9 +156,7 @@ 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); + Model CreateLocalModel(ModelInfo info, std::string local_path, std::string runtime_model_id); 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 76a46504a..b0ec9b44a 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -106,23 +106,17 @@ bool CompareModelsForSort(const Model& m1, const Model& m2) { Model::~Model() = default; Model::Model(Model&& other) noexcept - : cached_(other.cached_.load()), + : 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_)), 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); + // The mutex and unregistering state are intentionally not moved; moving while unregistering is invalid. // After vector move, selected_variant_ still points into the transferred buffer. other.download_manager_ = nullptr; other.model_load_manager_ = nullptr; @@ -131,23 +125,17 @@ Model::Model(Model&& other) noexcept Model& Model::operator=(Model&& other) noexcept { if (this != &other) { - { - 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); + 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_); selected_variant_.store(other.selected_variant_.load(std::memory_order_relaxed), std::memory_order_relaxed); + unregistering_ = false; other.download_manager_ = nullptr; other.model_load_manager_ = nullptr; other.selected_variant_.store(nullptr, std::memory_order_relaxed); @@ -182,14 +170,10 @@ Model Model::FromLocalRegistration(ModelInfo info, std::string local_path, DownloadManager& download_manager, ModelLoadManager& model_load_manager, - std::function unregister_callback, - std::function()> prepare_callback) { + std::string runtime_model_id) { 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(false); + model.runtime_model_id_ = std::move(runtime_model_id); return model; } @@ -266,11 +250,11 @@ const ModelInfo& Model::Info() const { return sv->Info(); } - const auto* info = current_info_.load(std::memory_order_acquire); - if (!info) { + if (!info_) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "model metadata is not initialized"); } - return *info; + + return *info_; } std::vector Model::Variants() const { @@ -296,22 +280,13 @@ bool Model::IsCached() const { return sv->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 std::filesystem::is_directory(local_path_, ec) && + std::filesystem::is_regular_file(std::filesystem::path(local_path_) / "genai_config.json", ec); } - return cached_; + return active_ && cached_; } bool Model::IsLoaded() const { @@ -319,10 +294,6 @@ bool Model::IsLoaded() const { return sv->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). @@ -406,10 +377,6 @@ void Model::Load(ExecutionProvider ep) { } } - 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_, runtime_model_id_, ep); @@ -425,10 +392,6 @@ 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(runtime_model_id_); } @@ -440,19 +403,8 @@ void Model::RemoveFromCache() { } 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; + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "local registrations are not cache entries; call Catalog::UnregisterModel instead"); } std::string path; @@ -489,40 +441,19 @@ void Model::Deactivate() { } } -void Model::EnsureLocalMetadata() const { - if (metadata_prepared_.load() || !prepare_callback_) { - return; - } - - 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() { - if (selected_variant_.load(std::memory_order_acquire)) { - for (auto* variant : Variants()) { - variant->BeginUnregister(); + if (IsContainer()) { + auto variants = Variants(); + size_t locked_count = 0; + try { + for (; locked_count < variants.size(); ++locked_count) { + variants[locked_count]->BeginUnregister(); + } + } catch (...) { + while (locked_count > 0) { + variants[--locked_count]->CancelUnregister(); + } + throw; } return; } @@ -532,11 +463,12 @@ void Model::BeginUnregister() { lifecycle_mutex_.unlock(); FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); } + unregistering_ = true; } void Model::CancelUnregister() { - if (selected_variant_.load(std::memory_order_acquire)) { + if (IsContainer()) { for (auto* variant : Variants()) { variant->CancelUnregister(); } @@ -547,6 +479,10 @@ void Model::CancelUnregister() { lifecycle_mutex_.unlock(); } +void Model::PublishInfo(ModelInfo info) { + info_ = std::make_unique(std::move(info)); +} + 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 e566e78e8..95e1533ae 100644 --- a/sdk_v2/cpp/src/model.h +++ b/sdk_v2/cpp/src/model.h @@ -6,7 +6,6 @@ #include #include #include -#include #include #include @@ -57,8 +56,7 @@ class Model { std::string local_path, DownloadManager& download_manager, ModelLoadManager& model_load_manager, - std::function unregister_callback, - std::function()> prepare_callback); + std::string runtime_model_id); // --- Container construction --- @@ -145,6 +143,8 @@ class Model { /// Mark this model and its variants inactive while retaining pointer validity. void Deactivate(); + + /// Serialize unregister with Load(); CancelUnregister releases the lock after success or rollback. void BeginUnregister(); void CancelUnregister(); @@ -171,7 +171,6 @@ class Model { } private: - void EnsureLocalMetadata() const; void PublishInfo(ModelInfo info); // Leaf data (default/empty for containers). @@ -183,17 +182,12 @@ 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. - mutable std::mutex metadata_mutex_; - mutable std::vector> info_snapshots_; - mutable std::atomic current_info_{nullptr}; + std::unique_ptr 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. @@ -208,6 +202,8 @@ class Model { // Guards variants_ across reader/writer threads (catalog refresh adding variants // while another thread enumerates via Variants()). mutable std::mutex state_mutex_; + + // Leaf lifecycle state. A Model must not be moved while an unregister operation holds this lock. mutable std::mutex lifecycle_mutex_; bool unregistering_ = false; }; diff --git a/sdk_v2/cpp/src/model_info.cc b/sdk_v2/cpp/src/model_info.cc index 907c14f56..56e2330aa 100644 --- a/sdk_v2/cpp/src/model_info.cc +++ b/sdk_v2/cpp/src/model_info.cc @@ -7,8 +7,6 @@ #include #include -#include -#include #include namespace fl { @@ -428,39 +426,4 @@ ModelInfo ModelInfoFromPropertyBagJson(const nlohmann::json& json) { 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 d4e72c090..10357352e 100644 --- a/sdk_v2/cpp/src/model_info.h +++ b/sdk_v2/cpp/src/model_info.h @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -95,7 +94,5 @@ 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/internal_api/c_api_test.cc b/sdk_v2/cpp/test/internal_api/c_api_test.cc index 28c687de5..108d538bd 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #include "internal_api/c_api_test_helpers.h" +#include "utils/temp_path.h" #include #include @@ -33,18 +34,20 @@ TEST(CApiTest, GetApiReturnsNullForFutureVersion) { EXPECT_EQ(api, nullptr); } -TEST(CApiTest, ModelInfoCloneIsAvailableInV2) { +TEST(CApiTest, SupportedVersionsReturnSameExpandedApiTables) { + const flApi* v0 = FoundryLocalGetApi(0); const flApi* v1 = FoundryLocalGetApi(1); const flApi* v2 = FoundryLocalGetApi(2); + ASSERT_NE(v0, nullptr); ASSERT_NE(v1, nullptr); ASSERT_NE(v2, nullptr); - const flModelApi* model_v1 = v1->GetModelApi(); - const flModelApi* model_v2 = v2->GetModelApi(); - ASSERT_NE(model_v1, nullptr); - ASSERT_NE(model_v2, nullptr); - EXPECT_EQ(model_v1->Info_Clone, nullptr); - EXPECT_NE(model_v2->Info_Clone, nullptr); + EXPECT_EQ(v0, v2); + EXPECT_EQ(v1, v2); + EXPECT_EQ(v1->GetCatalogApi(), v2->GetCatalogApi()); + EXPECT_EQ(v1->GetModelApi(), v2->GetModelApi()); + EXPECT_NE(v1->GetCatalogApi()->RegisterModel, nullptr); + EXPECT_NE(v1->GetModelApi()->CreateModelInfo, nullptr); } TEST(CApiTest, VersionReturnsNonNull) { @@ -111,53 +114,6 @@ 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 // ======================================================================== @@ -288,6 +244,68 @@ TEST(CApiTest, GetCatalogFromManager) { api->Manager_Release(mgr); } +TEST(CApiTest, LocalCatalogRegistersListsAndUnregistersWithoutOwningAssets) { + auto root = fl::test::TempPath::CreateTempDir("c_api_local_catalog"); + const auto model_path = root.path() / "model"; + const auto app_data_path = root.path() / "appdata"; + std::filesystem::create_directories(model_path); + std::ofstream(model_path / "genai_config.json") << R"({"model":{"type":"phi3"}})"; + + const flApi* api = GetApi(); + const flConfigurationApi* config_api = api->GetConfigurationApi(); + const flCatalogApi* catalog_api = api->GetCatalogApi(); + const flModelApi* model_api = api->GetModelApi(); + flConfiguration* config = nullptr; + ASSERT_FL_OK(api, config_api->Create("c-api-local-catalog", &config)); + ASSERT_FL_OK(api, config_api->SetAppDataDir(config, app_data_path.string().c_str())); + + flManager* manager = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &manager)); + flCatalog* catalog = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalogByType(manager, FOUNDRY_LOCAL_CATALOG_LOCAL, &catalog)); + ASSERT_NE(catalog, nullptr); + + flModelInfo* registration = nullptr; + ASSERT_FL_OK(api, model_api->CreateModelInfo(®istration)); + ASSERT_FL_OK(api, model_api->Info_SetStringProperty(registration, FOUNDRY_LOCAL_REG_MODEL_PATH, + model_path.string().c_str())); + ASSERT_FL_OK(api, model_api->Info_SetStringProperty(registration, FOUNDRY_LOCAL_REG_ALIAS, "c-api-model")); + ASSERT_FL_OK(api, model_api->Info_SetStringProperty(registration, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, + "chat-completion")); + ASSERT_FL_OK(api, model_api->Info_SetIntProperty(registration, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 17)); + + flModel* registered = nullptr; + ASSERT_FL_OK(api, catalog_api->RegisterModel(catalog, registration, ®istered)); + model_api->ReleaseModelInfo(registration); + ASSERT_NE(registered, nullptr); + + const flModelInfo* registered_info = nullptr; + ASSERT_FL_OK(api, model_api->GetInfo(registered, ®istered_info)); + EXPECT_STREQ(model_api->Info_GetId(registered_info), "c-api-model:0"); + EXPECT_STREQ(model_api->Info_GetTask(registered_info), "chat-completion"); + EXPECT_EQ(model_api->Info_GetIntProperty(registered_info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, -1), 17); + + flModelList* models = nullptr; + ASSERT_FL_OK(api, catalog_api->GetModels(catalog, &models)); + ASSERT_EQ(api->ModelList_Size(models), 1u); + EXPECT_NE(api->ModelList_GetAt(models, 0), nullptr); + api->ModelList_Release(models); + + StatusGuard remove_status{model_api->RemoveFromCache(registered), api}; + ASSERT_NE(remove_status.s, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(remove_status.s), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + + ASSERT_FL_OK(api, catalog_api->UnregisterModel(catalog, "c-api-model")); + EXPECT_TRUE(std::filesystem::exists(model_path / "genai_config.json")); + EXPECT_FALSE(std::filesystem::exists(model_path / "model_metadata.yml")); + ASSERT_FL_OK(api, catalog_api->GetModels(catalog, &models)); + EXPECT_EQ(api->ModelList_Size(models), 0u); + api->ModelList_Release(models); + + config_api->Configuration_Release(config); + api->Manager_Release(manager); +} + TEST(CApiTest, GetCatalogNameReturnsNonEmptyString) { const flApi* api = GetApi(); ASSERT_NE(api, nullptr); 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 c6b839d26..ab6ae90e0 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 @@ -7,11 +7,11 @@ #include #include +#include #include #include -#include -#include +#include namespace fl::test { namespace { @@ -21,22 +21,30 @@ class LocalModelCatalogTest : public ::testing::Test { 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}})"; + catalog_(MakeCatalog()) { + WriteConfig(model_dir_); + } + + LocalModelCatalog MakeCatalog() { + return LocalModelCatalog( + root_.path() / "appdata", + [this](ModelInfo info, std::string path, std::string runtime_model_id) { + return Model::FromLocalRegistration(std::move(info), std::move(path), bindings_.download_manager, + bindings_.model_load_manager, std::move(runtime_model_id)); + }, + NullLog()); + } + + static void WriteConfig(const std::filesystem::path& path) { + std::filesystem::create_directories(path); + std::ofstream(path / "genai_config.json") << R"({"model":{"type":"phi3","context_length":4096}})"; } - ModelInfo MakeInfo(std::string alias = "my-model") const { + ModelInfo MakeInfo(std::string alias = "my-model", std::string task = "chat-completion") const { ModelInfo info; SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, model_dir_.string()); SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, std::move(alias)); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, std::move(task)); return info; } @@ -46,325 +54,112 @@ class LocalModelCatalogTest : public ::testing::Test { LocalModelCatalog catalog_; }; -TEST_F(LocalModelCatalogTest, RegisterResolvesMetadataListsAndWritesFiles) { - auto* model = catalog_.RegisterModel(MakeInfo()); +TEST_F(LocalModelCatalogTest, RegisterPreservesCallerMetadataAndWritesOnlyAppDataIndex) { + auto info = MakeInfo(); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, "text,image"); + SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "text"); + SetModelInfoIntProperty(info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 321); + + auto* model = catalog_.RegisterModel(info); 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_EQ(model->Info().task, "chat-completion"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, std::string{}), + "text,image"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, std::string{}), + "text"); + EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, int64_t{-1}), 321); 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_TRUE(std::filesystem::exists(model_dir_ / "model_metadata.yml")); + EXPECT_FALSE(std::filesystem::exists(model_dir_ / "model_metadata.yml")); + 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")); + EXPECT_EQ(index["models"][0]["model_id"], "my-model:0"); + EXPECT_FALSE(index["models"][0].contains("metadata_prepared")); } -TEST_F(LocalModelCatalogTest, RejectsMissingInvalidAndDuplicateAliases) { - ModelInfo missing; +TEST_F(LocalModelCatalogTest, RegistrationRequiresExistingDirectoryAndParseableConfig) { + auto missing = MakeInfo(); + SetModelInfoStringProperty(missing, FOUNDRY_LOCAL_REG_MODEL_PATH, (root_.path() / "missing").string()); EXPECT_THROW(catalog_.RegisterModel(missing), Exception); - EXPECT_THROW(catalog_.RegisterModel(MakeInfo("bad alias")), Exception); - catalog_.RegisterModel(MakeInfo()); - EXPECT_THROW(catalog_.RegisterModel(MakeInfo()), Exception); + const auto no_config_path = root_.path() / "no-config"; + std::filesystem::create_directories(no_config_path); + auto no_config = MakeInfo("no-config"); + SetModelInfoStringProperty(no_config, FOUNDRY_LOCAL_REG_MODEL_PATH, no_config_path.string()); + EXPECT_THROW(catalog_.RegisterModel(no_config), Exception); + + const auto malformed_path = root_.path() / "malformed"; + std::filesystem::create_directories(malformed_path); + std::ofstream(malformed_path / "genai_config.json") << R"({"model":)"; + auto malformed = MakeInfo("malformed"); + SetModelInfoStringProperty(malformed, FOUNDRY_LOCAL_REG_MODEL_PATH, malformed_path.string()); + EXPECT_THROW(catalog_.RegisterModel(malformed), 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, RegistrationRequiresSupportedTask) { + ModelInfo missing_task; + SetModelInfoStringProperty(missing_task, FOUNDRY_LOCAL_REG_MODEL_PATH, model_dir_.string()); + SetModelInfoStringProperty(missing_task, FOUNDRY_LOCAL_REG_ALIAS, "missing-task"); + EXPECT_THROW(catalog_.RegisterModel(missing_task), Exception); + EXPECT_THROW(catalog_.RegisterModel(MakeInfo("invalid-task", "text-generation")), Exception); } -TEST_F(LocalModelCatalogTest, MissingDirectoryRemainsListedButIsNotCached) { - catalog_.RegisterModel(MakeInfo()); - std::filesystem::remove_all(model_dir_); +TEST_F(LocalModelCatalogTest, UnregisterPersistsAndPreservesAssets) { + auto* stale = catalog_.RegisterModel(MakeInfo()); + catalog_.UnregisterModel("my-model:0"); - ASSERT_EQ(catalog_.ListModels().size(), 1u); - EXPECT_TRUE(catalog_.GetCachedModels().empty()); + EXPECT_TRUE(catalog_.ListModels().empty()); + EXPECT_TRUE(std::filesystem::exists(model_dir_ / "genai_config.json")); + EXPECT_THROW(stale->Load(), Exception); + EXPECT_NO_THROW(stale->Unload()); + auto restored = MakeCatalog(); + EXPECT_TRUE(restored.ListModels().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); - +TEST_F(LocalModelCatalogTest, UnregisterWriteFailureLeavesModelActiveAndUsable) { + auto* model = catalog_.RegisterModel(MakeInfo()); 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, 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"); + const auto temp_index_path = root_.path() / "appdata" / "catalogs" / "local" / "local_models.json.tmp"; + std::filesystem::create_directory(temp_index_path); - std::filesystem::create_directories(deferred_path); - std::ofstream(deferred_path / "genai_config.json") - << R"({"model":{"type":"whisper","context_length":448}})"; + EXPECT_THROW(catalog_.UnregisterModel("my-model"), Exception); + EXPECT_TRUE(model->IsActive()); + EXPECT_EQ(catalog_.GetModelVariant("my-model:0"), model); - 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"); + float progress = 0.0f; + EXPECT_NO_THROW(model->Download([&progress](float value) { + progress = value; + return 0; + })); + EXPECT_EQ(progress, 100.0f); } -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); +TEST_F(LocalModelCatalogTest, TwoCatalogsReconcileRegisterUnregisterAndReregister) { + auto second = MakeCatalog(); + EXPECT_TRUE(second.ListModels().empty()); - 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* stale = catalog_.RegisterModel(MakeInfo()); + ASSERT_NE(stale, nullptr); + ASSERT_EQ(second.ListModels().size(), 1u); - 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); + second.UnregisterModel("my-model"); + EXPECT_TRUE(catalog_.ListModels().empty()); + EXPECT_FALSE(stale->IsActive()); + EXPECT_THROW(stale->Download(), Exception); + EXPECT_THROW(stale->Load(), Exception); + EXPECT_THROW(stale->RemoveFromCache(), Exception); - 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"); + auto* replacement = second.RegisterModel(MakeInfo()); + ASSERT_NE(replacement, nullptr); + EXPECT_NE(replacement->RuntimeId(), stale->RuntimeId()); + ASSERT_EQ(catalog_.ListModels().size(), 1u); + EXPECT_NE(catalog_.GetModelVariant("my-model:0"), stale); } TEST_F(LocalModelCatalogTest, PublicCatalogContractRejectsRegistration) { 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 db7d6a767..4353c40c2 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,55 +42,6 @@ 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 // ============================================================================ 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 d9615ffd5..2ddf2680f 100644 --- a/sdk_v2/cpp/test/internal_api/model_info_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_info_test.cc @@ -4,31 +4,12 @@ // Round-trip tests for ModelInfo JSON serialization/deserialization. // #include "model_info.h" -#include "utils/temp_path.h" - #include #include #include 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 // ======================================================================== diff --git a/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc b/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc index cb988ea02..309a4852b 100644 --- a/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc +++ b/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// End-to-end coverage for creating missing local-model metadata during registration and then running inference. +// End-to-end coverage for registering existing local model assets and then running inference. #include "model_fixture.h" @@ -56,7 +56,7 @@ std::optional GetByomSourceModelPath() { } } -void StageModelWithoutMetadata(const fs::path& source, const fs::path& destination) { +void StageModelAssets(const fs::path& source, const fs::path& destination) { for (const auto& entry : fs::recursive_directory_iterator(source)) { const auto relative_path = fs::relative(entry.path(), source); if (relative_path.filename() == "inference_model.json" || relative_path.filename() == "model_metadata.yml") { @@ -112,7 +112,7 @@ class LocalRegistrationGuard { } // namespace -TEST(ByomE2eTest, RegisterModelCreatesMissingMetadataAndRunsChatInference) { +TEST(ByomE2eTest, RegisterModelPreservesAssetsAndRunsChatInference) { using namespace foundry_local; const auto source_model_path = GetByomSourceModelPath(); @@ -124,7 +124,7 @@ TEST(ByomE2eTest, RegisterModelCreatesMissingMetadataAndRunsChatInference) { auto temp_root = fl::test::TempPath::CreateTempDir("fl_byom_e2e_"); const auto staged_model_path = temp_root.path() / "model"; fs::create_directories(staged_model_path); - StageModelWithoutMetadata(*source_model_path, staged_model_path); + StageModelAssets(*source_model_path, staged_model_path); ASSERT_TRUE(fs::exists(staged_model_path / "genai_config.json")); ASSERT_FALSE(fs::exists(staged_model_path / "inference_model.json")); @@ -135,6 +135,7 @@ TEST(ByomE2eTest, RegisterModelCreatesMissingMetadataAndRunsChatInference) { ModelInfo registration; registration.SetStringProperty(FOUNDRY_LOCAL_REG_MODEL_PATH, staged_model_path.string().c_str()); registration.SetStringProperty(FOUNDRY_LOCAL_REG_ALIAS, registration_alias.c_str()); + registration.SetStringProperty(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"); LocalRegistrationGuard registered(local_catalog, local_catalog.RegisterModel(registration), registration_alias); auto& model = registered.model(); @@ -143,7 +144,8 @@ TEST(ByomE2eTest, RegisterModelCreatesMissingMetadataAndRunsChatInference) { EXPECT_EQ(model.GetInfo().Task(), "chat-completion"); EXPECT_TRUE(model.IsCached()); EXPECT_FALSE(model.IsLoaded()); - EXPECT_TRUE(fs::exists(staged_model_path / "model_metadata.yml")); + EXPECT_TRUE(fs::is_regular_file(staged_model_path / "genai_config.json")); + EXPECT_FALSE(fs::exists(staged_model_path / "model_metadata.yml")); EXPECT_FALSE(fs::exists(staged_model_path / "inference_model.json")); model.Load(); From 49ae75c0c98342e7dfe7fe63654f56b116fd4997 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:11:29 -0700 Subject: [PATCH 11/17] Use one : ID instead of separate values; Use filesize_mb, not a bytes property --- .../include/foundry_local/foundry_local_c.h | 14 +- .../include/foundry_local/foundry_local_cpp.h | 8 +- .../foundry_local/foundry_local_cpp.inline.h | 6 +- sdk_v2/cpp/src/c_api.cc | 8 +- sdk_v2/cpp/src/catalog.h | 3 +- sdk_v2/cpp/src/catalog/base_model_catalog.cc | 34 ++- sdk_v2/cpp/src/catalog/base_model_catalog.h | 3 + sdk_v2/cpp/src/catalog/local_model_catalog.cc | 187 +++++++++++------ sdk_v2/cpp/src/catalog/local_model_catalog.h | 8 +- sdk_v2/cpp/src/model.cc | 162 ++++++++++++++- sdk_v2/cpp/src/model.h | 18 ++ sdk_v2/cpp/src/model_info.cc | 12 +- sdk_v2/cpp/test/internal_api/c_api_test.cc | 21 +- .../internal_api/local_model_catalog_test.cc | 194 +++++++++++++++--- .../sdk_api/bring_your_own_model_e2e_test.cc | 36 ++-- 15 files changed, 555 insertions(+), 159 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 f3cef2199..a61506e33 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -268,10 +268,6 @@ typedef enum flTensorDataType { #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) #define FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_REASONING_INT "supports_reasoning" ///< optional bool (not set or -1=unknown, 0=false, 1=true) @@ -280,8 +276,6 @@ 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 @@ -994,8 +988,12 @@ 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, + /// Register a model in a local catalog without taking ownership of its assets. + /// `model_path` must identify a model directory containing genai_config.json. + /// `model_id` must use the canonical `:` format and be unique in the local catalog. + /// The metadata is copied; model identity and location are taken only from the explicit arguments. + FL_API_STATUS(RegisterModel, _In_ flCatalog* catalog, _In_ const char* model_path, + _In_ const char* model_id, _In_ const flModelInfo* metadata, _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); 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 71261bf40..96f3a4f96 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -803,7 +803,10 @@ 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&) { + + /// Register existing local model assets. `model_id` must use `:`; metadata is copied. + /// The catalog does not take ownership of `model_path` and never deletes its contents. + virtual std::unique_ptr RegisterModel(const std::string&, const std::string&, const ModelInfo&) { throw Error("models can only be registered in a local catalog", FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); } virtual void UnregisterModel(const std::string&) { @@ -834,7 +837,8 @@ 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; + std::unique_ptr RegisterModel(const std::string& model_path, const std::string& model_id, + const ModelInfo& metadata) override; void UnregisterModel(const std::string& alias_or_model_id) override; private: 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 a9f90e553..57724208f 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 @@ -657,9 +657,11 @@ inline ModelList Catalog::GetModelVersions(const std::string& model_alias, return ModelList(*models); } -inline std::unique_ptr Catalog::RegisterModel(const ModelInfo& model_info) { +inline std::unique_ptr Catalog::RegisterModel(const std::string& model_path, const std::string& model_id, + const ModelInfo& metadata) { flModel* model = nullptr; - Check(detail::catalog_api()->RegisterModel(handle_.get_mutable(), model_info.native_handle(), &model)); + Check(detail::catalog_api()->RegisterModel(handle_.get_mutable(), model_path.c_str(), model_id.c_str(), + metadata.native_handle(), &model)); return std::make_unique(*model); } diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index 6cce5994d..afe8eda66 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -736,14 +736,14 @@ 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) { +FL_API_STATUS_IMPL(Catalog_RegisterModelImpl, flCatalog* catalog, const char* model_path, + const char* model_id, const flModelInfo* metadata, flModel** out_model) { API_IMPL_BEGIN - if (!catalog || !model_info || !out_model) { + if (!catalog || !model_path || !model_id || !metadata || !out_model) { return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); } - *out_model = AsHandle(catalog->impl.RegisterModel(*AsImpl(model_info))); + *out_model = AsHandle(catalog->impl.RegisterModel(model_path, model_id, *AsImpl(metadata))); return nullptr; API_IMPL_END } diff --git a/sdk_v2/cpp/src/catalog.h b/sdk_v2/cpp/src/catalog.h index 63b581df3..e65170842 100644 --- a/sdk_v2/cpp/src/catalog.h +++ b/sdk_v2/cpp/src/catalog.h @@ -67,7 +67,8 @@ 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*/) { + virtual Model* RegisterModel(const std::string& /*model_path*/, const std::string& /*model_id*/, + const ModelInfo& /*metadata*/) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "models can only be registered in a local catalog"); } diff --git a/sdk_v2/cpp/src/catalog/base_model_catalog.cc b/sdk_v2/cpp/src/catalog/base_model_catalog.cc index ffeab7d1b..665e35631 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.cc @@ -65,11 +65,20 @@ void BaseModelCatalog::PopulateModels(std::vector variants) const { } const auto incoming = alias_to_model.find(stored.model->Alias()); - if (incoming == alias_to_model.end() || incoming->second.RuntimeId() != stored.model->RuntimeId()) { + if (incoming == alias_to_model.end()) { + if (!stored.model->TryDeactivateForRefresh()) { + continue; + } stored.active = false; - stored.model->Deactivate(); existing_aliases.erase(stored.model->Alias()); + continue; } + + if (!stored.model->TryReconcileVariants(incoming->second)) { + alias_to_model.erase(incoming); + continue; + } + alias_to_model.erase(incoming); } } @@ -433,6 +442,27 @@ bool BaseModelCatalog::RetireModel(const std::string& alias_or_model_id) { return false; } +bool BaseModelCatalog::CommitUnregister(Model* model, const std::string& alias_or_model_id) { + std::lock_guard lock(mutex_); + for (auto& stored : models_) { + if (!stored.active || stored.model.get() != model) { + continue; + } + + if (stored.model->Alias() == alias_or_model_id) { + stored.active = false; + stored.model->Deactivate(); + } else if (!stored.model->RetireVariant(alias_or_model_id)) { + stored.active = false; + } + + 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 292777557..a422fdd1f 100644 --- a/sdk_v2/cpp/src/catalog/base_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/base_model_catalog.h @@ -56,6 +56,9 @@ class BaseModelCatalog : public ICatalog { /// Remove a model from catalog lookup while retaining its storage for pointer safety. bool RetireModel(const std::string& alias_or_model_id); + /// Commit an unregister operation against the exact container whose lifecycle lock is held. + bool CommitUnregister(Model* model, 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. diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index a697b5e78..efe194beb 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -10,11 +10,11 @@ #include #include +#include #include #include #include #include -#include #include #include #include @@ -29,7 +29,11 @@ namespace fl { namespace { constexpr const char* kRegistrationIdProperty = "_local_registration_id"; -const std::regex kAliasPattern("[A-Za-z0-9][A-Za-z0-9._-]*"); +constexpr const char* kLegacyModelPathProperty = "model_path"; +constexpr const char* kLegacyAliasProperty = "alias"; +constexpr const char* kLegacyVersionProperty = "version"; +const std::regex kModelNamePattern("[A-Za-z0-9][A-Za-z0-9._-]*"); +std::mutex kLocalCatalogMutationMutex; const std::unordered_set kSupportedTasks = { "automatic-speech-recognition", "chat-completion", @@ -55,11 +59,54 @@ bool HasParentTraversal(const std::filesystem::path& path) { return std::any_of(path.begin(), path.end(), [](const auto& component) { return component == ".."; }); } +struct ParsedModelId { + std::string name; + int version; +}; + +ParsedModelId ParseModelId(const std::string& model_id) { + const auto separator = model_id.find(':'); + if (separator == std::string::npos || separator == 0 || separator + 1 == model_id.size() || + model_id.find(':', separator + 1) != std::string::npos) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_id must have format :"); + } + + auto name = model_id.substr(0, separator); + if (!std::regex_match(name, kModelNamePattern)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_id name must match [a-zA-Z0-9][a-zA-Z0-9._-]*"); + } + + const auto version_text = model_id.substr(separator + 1); + if ((version_text.size() > 1 && version_text.front() == '0') || + !std::all_of(version_text.begin(), version_text.end(), [](char c) { return c >= '0' && c <= '9'; })) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_id version must be a canonical non-negative integer"); + } + + int version = 0; + const auto [end, error] = std::from_chars(version_text.data(), version_text.data() + version_text.size(), version); + if (error != std::errc{} || end != version_text.data() + version_text.size()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_id version is out of range"); + } + + return {std::move(name), version}; +} + +void RemoveLegacyRegistrationProperties(ModelInfo& info) { + info.string_properties.erase(kLegacyModelPathProperty); + info.string_properties.erase(kLegacyAliasProperty); + info.string_properties.erase(kRegistrationIdProperty); + info.string_properties.erase(kLegacyVersionProperty); + info.int_properties.erase(kLegacyModelPathProperty); + info.int_properties.erase(kLegacyAliasProperty); + info.int_properties.erase(kRegistrationIdProperty); + info.int_properties.erase(kLegacyVersionProperty); +} + nlohmann::json RegistrationToJson(const LocalModelCatalog::Registration& registration) { return { - {"alias", registration.info.alias}, {"model_id", registration.info.model_id}, {"model_path", registration.model_path}, + {"registration_id", registration.registration_id}, {"registered_at", registration.info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, std::string{})}, {"properties", ModelInfoToPropertyBagJson(registration.info)}, @@ -90,22 +137,17 @@ std::vector LocalModelCatalog::FetchModels() const { return models; } -Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { - const auto* model_path_value = model_info.GetPropertyStr(FOUNDRY_LOCAL_REG_MODEL_PATH); - if (!model_path_value || model_path_value->empty()) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_path is required"); - } +Model* LocalModelCatalog::RegisterModel(const std::string& model_path_value, const std::string& model_id, + const ModelInfo& metadata) { + std::lock_guard mutation_guard(kLocalCatalogMutationMutex); - const auto* alias_value = model_info.GetPropertyStr(FOUNDRY_LOCAL_REG_ALIAS); - if (!alias_value || alias_value->empty()) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias is required"); + if (model_path_value.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_path is required"); } - if (!std::regex_match(*alias_value, kAliasPattern)) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias must match [a-zA-Z0-9][a-zA-Z0-9._-]*"); - } + const auto parsed_id = ParseModelId(model_id); - const std::filesystem::path supplied_path(*model_path_value); + const 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"); } @@ -123,7 +165,7 @@ Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { GenAIConfig::LoadFromFile(config_path.string()); - const auto* task = model_info.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR); + const auto* task = metadata.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR); if (!task || task->empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "task is required"); } @@ -138,21 +180,19 @@ Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { FileLock file_lock(lock_path_); auto registrations = LoadRegistrations(); const auto duplicate = std::find_if(registrations.begin(), registrations.end(), [&](const auto& existing) { - return existing.info.alias == *alias_value; + return existing.info.model_id == model_id; }); if (duplicate != registrations.end()) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, - "a model with alias '" + *alias_value + "' is already registered"); + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model_id is already registered: " + model_id); } - registration = {ResolveMetadata(model_info, model_path.string(), *alias_value), model_path.string()}; - auto registration_id = registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); + registration = {ResolveMetadata(metadata, model_id, parsed_id.name, parsed_id.version), model_path.string(), + std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count())}; while (std::any_of(registrations.begin(), registrations.end(), [&](const auto& existing) { - return existing.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == registration_id; + return existing.registration_id == registration.registration_id; })) { - registration_id += "-1"; + registration.registration_id += "-1"; } - SetModelInfoStringProperty(registration.info, kRegistrationIdProperty, std::move(registration_id)); registrations.push_back(registration); SaveRegistrations(registrations); @@ -160,8 +200,7 @@ Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { ListModels(); auto* model = GetModelVariant(registration.info.model_id); - const auto runtime_id = - "local/" + registration.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}); + const auto runtime_id = "local/" + registration.registration_id; if (!model || model->RuntimeId() != runtime_id) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "registered model was not available after catalog refresh"); } @@ -170,6 +209,8 @@ Model* LocalModelCatalog::RegisterModel(const ModelInfo& model_info) { } void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { + std::lock_guard mutation_guard(kLocalCatalogMutationMutex); + if (alias_or_model_id.empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "alias_or_model_id must not be empty"); } @@ -177,16 +218,27 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { ListModels(); auto* model = GetModel(alias_or_model_id); if (!model) { - model = GetModelVariant(alias_or_model_id); + auto* variant = GetModelVariant(alias_or_model_id); + if (variant) { + model = GetModel(variant->Alias()); + } } if (!model) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model not found: " + alias_or_model_id); } model->BeginUnregister(); + bool unregister_in_progress = true; try { - if (model->IsLoaded()) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "cannot unregister a loaded model; unload it first"); + for (const auto* variant : model->Variants()) { + if (variant->IsLoaded()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "cannot unregister a loaded model; unload it first"); + } + } + + const bool unregister_alias = model->Alias() == alias_or_model_id; + if (!unregister_alias && !model->PrepareRetireVariant(alias_or_model_id)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "model not found: " + alias_or_model_id); } { @@ -204,30 +256,31 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { SaveRegistrations(registrations); } - ListModels(); + if (!CommitUnregister(model, alias_or_model_id)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "unregistered model was not present in the in-memory catalog"); + } + model->CancelUnregister(); + unregister_in_progress = false; + ListModels(); } catch (...) { - model->CancelUnregister(); + if (unregister_in_progress) { + model->CancelUnregister(); + } throw; } } -ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const std::string& model_path, - const std::string& alias) const { +ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const std::string& model_id, + const std::string& name, int version) const { auto resolved = metadata; - 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"); - } + RemoveLegacyRegistrationProperties(resolved); - resolved.alias = alias; - resolved.name = alias; - resolved.version = static_cast(version); - resolved.model_id = alias + ":" + std::to_string(version); + resolved.alias = name; + resolved.name = name; + resolved.version = version; + resolved.model_id = model_id; 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"); } @@ -238,8 +291,6 @@ ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const st const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, UtcTimestamp(now)); - const auto registration_id = std::chrono::high_resolution_clock::now().time_since_epoch().count(); - SetModelInfoStringProperty(resolved, kRegistrationIdProperty, std::to_string(registration_id)); return resolved; } @@ -254,50 +305,51 @@ std::vector LocalModelCatalog::LoadRegistration try { nlohmann::json root; stream >> root; - if (!root.is_object() || root.value("version", 0) != 1 || !root.contains("models") || - !root["models"].is_array()) { + const auto schema_version = root.value("version", 0); + if (!root.is_object() || (schema_version != 1 && schema_version != 2) || !root.contains("models") || + !root["models"].is_array()) { logger_.Log(LogLevel::Warning, "Ignoring malformed local model registration index: " + index_path_.string()); return {}; } for (const auto& item : root["models"]) { try { - if (!item.is_object() || !item.contains("model_path") || !item["model_path"].is_string() || - !item.contains("properties")) { + if (!item.is_object() || !item.contains("model_id") || !item["model_id"].is_string() || + !item.contains("model_path") || !item["model_path"].is_string() || !item.contains("properties")) { continue; } auto info = ModelInfoFromPropertyBagJson(item["properties"]); - const auto* registration_id = info.GetPropertyStr(kRegistrationIdProperty); - const auto* alias = info.GetPropertyStr(FOUNDRY_LOCAL_REG_ALIAS); - if (!registration_id || registration_id->empty() || !alias || !std::regex_match(*alias, kAliasPattern)) { - continue; + std::string registration_id; + if (item.contains("registration_id") && item["registration_id"].is_string()) { + registration_id = item["registration_id"].get(); + } else if (const auto* legacy_registration_id = info.GetPropertyStr(kRegistrationIdProperty)) { + registration_id = *legacy_registration_id; } - - const auto version = info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_VERSION_INT, int64_t{0}); - if (version < 0 || version > std::numeric_limits::max()) { + if (registration_id.empty()) { continue; } + const auto model_id = item["model_id"].get(); + const auto parsed_id = ParseModelId(model_id); 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); + RemoveLegacyRegistrationProperties(info); + info.alias = parsed_id.name; + info.name = parsed_id.name; + info.version = parsed_id.version; + info.model_id = model_id; info.uri.clear(); - SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, model_path.string()); const auto duplicate = std::find_if(registrations.begin(), registrations.end(), [&](const auto& existing) { - return existing.info.alias == info.alias || existing.info.model_id == info.model_id || - existing.info.GetPropertyWithDefault(kRegistrationIdProperty, std::string{}) == *registration_id; + return existing.info.model_id == info.model_id || existing.registration_id == registration_id; }); if (duplicate == registrations.end()) { - registrations.push_back({std::move(info), model_path.string()}); + registrations.push_back({std::move(info), model_path.string(), std::move(registration_id)}); } } catch (const std::exception& ex) { logger_.Log(LogLevel::Warning, std::string("Ignoring malformed local model registration: ") + ex.what()); @@ -317,7 +369,7 @@ void LocalModelCatalog::SaveRegistrations(const std::vector& regis models.push_back(RegistrationToJson(registration)); } - const nlohmann::json root = {{"version", 1}, {"catalog_name", "local"}, {"models", std::move(models)}}; + const nlohmann::json root = {{"version", 2}, {"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); @@ -348,8 +400,7 @@ void LocalModelCatalog::SaveRegistrations(const std::vector& regis } 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, "local/" + registration_id); + return model_factory_(registration.info, registration.model_path, "local/" + registration.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 f83829c11..76b166432 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.h @@ -17,12 +17,14 @@ class LocalModelCatalog final : public BaseModelCatalog { LocalModelCatalog(std::filesystem::path app_data_dir, ModelFactory model_factory, ILogger& logger); - Model* RegisterModel(const ModelInfo& model_info) override; + Model* RegisterModel(const std::string& model_path, const std::string& model_id, + const ModelInfo& metadata) override; void UnregisterModel(const std::string& alias_or_model_id) override; struct Registration { ModelInfo info; std::string model_path; + std::string registration_id; }; protected: @@ -30,8 +32,8 @@ class LocalModelCatalog final : public BaseModelCatalog { bool IsAuthoritativeSnapshot() const override { return true; } private: - ModelInfo ResolveMetadata(const ModelInfo& metadata, const std::string& model_path, - const std::string& alias) const; + ModelInfo ResolveMetadata(const ModelInfo& metadata, const std::string& model_id, + const std::string& name, int version) const; std::vector LoadRegistrations() const; void SaveRegistrations(const std::vector& registrations) const; Model CreateModel(const Registration& registration) const; diff --git a/sdk_v2/cpp/src/model.cc b/sdk_v2/cpp/src/model.cc index b0ec9b44a..7624ebb09 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -115,7 +115,9 @@ Model::Model(Model&& other) noexcept download_manager_(other.download_manager_), model_load_manager_(other.model_load_manager_), variants_(std::move(other.variants_)), - selected_variant_(other.selected_variant_.load(std::memory_order_relaxed)) { + retired_variants_(std::move(other.retired_variants_)), + selected_variant_(other.selected_variant_.load(std::memory_order_relaxed)), + selection_is_explicit_(other.selection_is_explicit_) { // The mutex and unregistering state are intentionally not moved; moving while unregistering is invalid. // After vector move, selected_variant_ still points into the transferred buffer. other.download_manager_ = nullptr; @@ -134,7 +136,9 @@ Model& Model::operator=(Model&& other) noexcept { download_manager_ = other.download_manager_; model_load_manager_ = other.model_load_manager_; variants_ = std::move(other.variants_); + retired_variants_ = std::move(other.retired_variants_); selected_variant_.store(other.selected_variant_.load(std::memory_order_relaxed), std::memory_order_relaxed); + selection_is_explicit_ = other.selection_is_explicit_; unregistering_ = false; other.download_manager_ = nullptr; other.model_load_manager_ = nullptr; @@ -203,6 +207,121 @@ void Model::AddVariant(Model variant) { variants_.insert(pos, std::make_unique(std::move(variant))); } +bool Model::TryReconcileVariants(Model& incoming) { + if (!IsContainer() || !incoming.IsContainer() || Alias() != incoming.Alias()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "TryReconcileVariants requires containers with the same alias"); + } + + std::unique_lock lifecycle_lock(lifecycle_mutex_, std::try_to_lock); + if (!lifecycle_lock.owns_lock()) { + return false; + } + + std::scoped_lock lock(state_mutex_, incoming.state_mutex_); + variants_.reserve(variants_.size() + incoming.variants_.size()); + retired_variants_.reserve(retired_variants_.size() + variants_.size()); + + auto* previous_selection = selected_variant_.load(std::memory_order_acquire); + const auto preserve_selection = selection_is_explicit_; + + for (auto current = variants_.begin(); current != variants_.end();) { + const auto match = std::find_if(incoming.variants_.begin(), incoming.variants_.end(), [&](const auto& candidate) { + return (*current)->Info().model_id == candidate->Info().model_id && + (*current)->RuntimeId() == candidate->RuntimeId(); + }); + if (match != incoming.variants_.end()) { + incoming.variants_.erase(match); + ++current; + continue; + } + + (*current)->Deactivate(); + retired_variants_.push_back(std::move(*current)); + current = variants_.erase(current); + } + + for (auto& variant : incoming.variants_) { + variants_.push_back(std::move(variant)); + } + incoming.variants_.clear(); + + std::sort(variants_.begin(), variants_.end(), [](const auto& left, const auto& right) { + return CompareModelsForSort(*left, *right); + }); + + const auto retained_selection = std::find_if(variants_.begin(), variants_.end(), [&](const auto& variant) { + return variant.get() == previous_selection; + }); + const auto cached_selection = std::find_if(variants_.begin(), variants_.end(), [](const auto& variant) { + return variant->IsCached(); + }); + const auto selected = preserve_selection && retained_selection != variants_.end() + ? retained_selection + : (cached_selection != variants_.end() ? cached_selection : variants_.begin()); + selection_is_explicit_ = preserve_selection && retained_selection != variants_.end(); + selected_variant_.store(selected->get(), std::memory_order_release); + return true; +} + +bool Model::TryDeactivateForRefresh() { + std::unique_lock lifecycle_lock(lifecycle_mutex_, std::try_to_lock); + if (!lifecycle_lock.owns_lock()) { + return false; + } + + Deactivate(); + return true; +} + +bool Model::PrepareRetireVariant(const std::string& model_id) { + if (!IsContainer()) { + return false; + } + + std::lock_guard lock(state_mutex_); + const auto variant = std::find_if(variants_.begin(), variants_.end(), [&](const auto& candidate) { + return candidate->Info().model_id == model_id; + }); + if (variant == variants_.end()) { + return false; + } + + retired_variants_.reserve(retired_variants_.size() + 1); + return true; +} + +bool Model::RetireVariant(const std::string& model_id) { + std::lock_guard lock(state_mutex_); + const auto variant = std::find_if(variants_.begin(), variants_.end(), [&](const auto& candidate) { + return candidate->Info().model_id == model_id; + }); + if (variant == variants_.end()) { + return !variants_.empty(); + } + + auto* retired = variant->get(); + retired->Deactivate(); + retired_variants_.push_back(std::move(*variant)); + variants_.erase(variant); + + if (variants_.empty()) { + selection_is_explicit_ = false; + selected_variant_.store(retired, std::memory_order_release); + return false; + } + + if (selected_variant_.load(std::memory_order_acquire) == retired) { + const auto cached = std::find_if(variants_.begin(), variants_.end(), [](const auto& candidate) { + return candidate->IsCached(); + }); + const auto selected = cached != variants_.end() ? cached : variants_.begin(); + selection_is_explicit_ = false; + selected_variant_.store(selected->get(), std::memory_order_release); + } + + return true; +} + bool Model::CompareBestFirst(const Model& a, const Model& b) { return CompareModelsForSort(a, b); } @@ -217,11 +336,13 @@ void Model::SelectDefaultVariant() { for (auto& v : variants_) { if (v->IsCached()) { + selection_is_explicit_ = false; selected_variant_.store(v.get(), std::memory_order_release); return; } } + selection_is_explicit_ = false; selected_variant_.store(variants_.front().get(), std::memory_order_release); } @@ -443,16 +564,38 @@ void Model::Deactivate() { void Model::BeginUnregister() { if (IsContainer()) { - auto variants = Variants(); + lifecycle_mutex_.lock(); + if (!active_ || unregistering_) { + lifecycle_mutex_.unlock(); + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model is no longer registered"); + } + unregistering_ = true; + + try { + std::lock_guard state_lock(state_mutex_); + unregistering_variants_.clear(); + unregistering_variants_.reserve(variants_.size()); + for (const auto& variant : variants_) { + unregistering_variants_.push_back(variant.get()); + } + } catch (...) { + unregistering_ = false; + lifecycle_mutex_.unlock(); + throw; + } + size_t locked_count = 0; try { - for (; locked_count < variants.size(); ++locked_count) { - variants[locked_count]->BeginUnregister(); + for (; locked_count < unregistering_variants_.size(); ++locked_count) { + unregistering_variants_[locked_count]->BeginUnregister(); } } catch (...) { while (locked_count > 0) { - variants[--locked_count]->CancelUnregister(); + unregistering_variants_[--locked_count]->CancelUnregister(); } + unregistering_variants_.clear(); + unregistering_ = false; + lifecycle_mutex_.unlock(); throw; } return; @@ -468,10 +611,13 @@ void Model::BeginUnregister() { } void Model::CancelUnregister() { - if (IsContainer()) { - for (auto* variant : Variants()) { + if (!unregistering_variants_.empty()) { + for (auto* variant : unregistering_variants_) { variant->CancelUnregister(); } + unregistering_variants_.clear(); + unregistering_ = false; + lifecycle_mutex_.unlock(); return; } @@ -490,8 +636,10 @@ void Model::SelectVariant(const Model& variant) { "with all variants available."); } + std::lock_guard lock(state_mutex_); for (auto& v : variants_) { if (v.get() == &variant) { + selection_is_explicit_ = true; selected_variant_.store(v.get(), std::memory_order_release); return; } diff --git a/sdk_v2/cpp/src/model.h b/sdk_v2/cpp/src/model.h index 95e1533ae..5439d996e 100644 --- a/sdk_v2/cpp/src/model.h +++ b/sdk_v2/cpp/src/model.h @@ -73,6 +73,21 @@ class Model { /// container has its full variant set. void AddVariant(Model variant); + /// Reconcile this container with a fresh authoritative container while preserving leaves whose model ID and + /// private runtime ID are unchanged. Returns false without changing either container when unregister is in progress. + /// Removed/replaced leaves are deactivated but retained in container-owned storage for outstanding handle safety. + bool TryReconcileVariants(Model& incoming); + + /// Deactivate this container for an authoritative refresh unless unregister currently owns its lifecycle lock. + bool TryDeactivateForRefresh(); + + /// Reserve storage for retiring one variant. Call before persisting an unregister operation so the later + /// in-memory commit cannot fail while transferring ownership of an outstanding model handle. + bool PrepareRetireVariant(const std::string& model_id); + + /// Retire one variant after PrepareRetireVariant succeeds. Returns true when active variants remain. + bool RetireVariant(const std::string& model_id); + /// Choose the default selected variant from the current sorted variant list: /// first cached variant if any, else the best variant. /// Requires IsContainer() to be true. @@ -197,7 +212,9 @@ class Model { // Container data (empty/null for leaves). unique_ptr keeps Model addresses // stable across vector growth/reordering. std::vector> variants_; + std::vector> retired_variants_; std::atomic selected_variant_{nullptr}; // non-null = this is a container + bool selection_is_explicit_ = false; // guarded by state_mutex_ // Guards variants_ across reader/writer threads (catalog refresh adding variants // while another thread enumerates via Variants()). @@ -206,6 +223,7 @@ class Model { // Leaf lifecycle state. A Model must not be moved while an unregister operation holds this lock. mutable std::mutex lifecycle_mutex_; bool unregistering_ = false; + std::vector unregistering_variants_; // exact container snapshot locked by BeginUnregister }; } // namespace fl diff --git a/sdk_v2/cpp/src/model_info.cc b/sdk_v2/cpp/src/model_info.cc index 56e2330aa..324ec85de 100644 --- a/sdk_v2/cpp/src/model_info.cc +++ b/sdk_v2/cpp/src/model_info.cc @@ -346,9 +346,7 @@ nlohmann::json ModelInfoToJson(const ModelInfo& info) { } 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) { + if (key == FOUNDRY_LOCAL_MODEL_PROP_TASK_STR) { info.task = value; } else if (key == FOUNDRY_LOCAL_MODEL_PROP_EP_STR) { info.execution_provider = value; @@ -360,10 +358,6 @@ void SetModelInfoStringProperty(ModelInfo& info, std::string key, std::string va } 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; } @@ -397,12 +391,10 @@ ModelInfo ModelInfoFromPropertyBagJson(const nlohmann::json& json) { } 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 || + const bool known_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 || 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 108d538bd..44ea4601b 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -265,23 +265,24 @@ TEST(CApiTest, LocalCatalogRegistersListsAndUnregistersWithoutOwningAssets) { ASSERT_FL_OK(api, api->Manager_GetCatalogByType(manager, FOUNDRY_LOCAL_CATALOG_LOCAL, &catalog)); ASSERT_NE(catalog, nullptr); - flModelInfo* registration = nullptr; - ASSERT_FL_OK(api, model_api->CreateModelInfo(®istration)); - ASSERT_FL_OK(api, model_api->Info_SetStringProperty(registration, FOUNDRY_LOCAL_REG_MODEL_PATH, - model_path.string().c_str())); - ASSERT_FL_OK(api, model_api->Info_SetStringProperty(registration, FOUNDRY_LOCAL_REG_ALIAS, "c-api-model")); - ASSERT_FL_OK(api, model_api->Info_SetStringProperty(registration, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, + flModelInfo* metadata = nullptr; + ASSERT_FL_OK(api, model_api->CreateModelInfo(&metadata)); + ASSERT_FL_OK(api, model_api->Info_SetStringProperty(metadata, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion")); - ASSERT_FL_OK(api, model_api->Info_SetIntProperty(registration, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 17)); + ASSERT_FL_OK(api, model_api->Info_SetIntProperty(metadata, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 17)); flModel* registered = nullptr; - ASSERT_FL_OK(api, catalog_api->RegisterModel(catalog, registration, ®istered)); - model_api->ReleaseModelInfo(registration); + ASSERT_FL_OK(api, catalog_api->RegisterModel(catalog, model_path.string().c_str(), "c-api-model:3", metadata, + ®istered)); + model_api->ReleaseModelInfo(metadata); ASSERT_NE(registered, nullptr); const flModelInfo* registered_info = nullptr; ASSERT_FL_OK(api, model_api->GetInfo(registered, ®istered_info)); - EXPECT_STREQ(model_api->Info_GetId(registered_info), "c-api-model:0"); + EXPECT_STREQ(model_api->Info_GetId(registered_info), "c-api-model:3"); + EXPECT_STREQ(model_api->Info_GetName(registered_info), "c-api-model"); + EXPECT_STREQ(model_api->Info_GetAlias(registered_info), "c-api-model"); + EXPECT_EQ(model_api->Info_GetVersion(registered_info), 3); EXPECT_STREQ(model_api->Info_GetTask(registered_info), "chat-completion"); EXPECT_EQ(model_api->Info_GetIntProperty(registered_info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, -1), 17); 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 ab6ae90e0..72becd818 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 @@ -12,6 +12,7 @@ #include #include #include +#include namespace fl::test { namespace { @@ -40,14 +41,16 @@ class LocalModelCatalogTest : public ::testing::Test { std::ofstream(path / "genai_config.json") << R"({"model":{"type":"phi3","context_length":4096}})"; } - ModelInfo MakeInfo(std::string alias = "my-model", std::string task = "chat-completion") const { + ModelInfo MakeMetadata(std::string task = "chat-completion") const { ModelInfo info; - SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_MODEL_PATH, model_dir_.string()); - SetModelInfoStringProperty(info, FOUNDRY_LOCAL_REG_ALIAS, std::move(alias)); SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, std::move(task)); return info; } + Model* Register(std::string model_id = "my-model:1") { + return catalog_.RegisterModel(model_dir_.string(), model_id, MakeMetadata()); + } + TempPath root_; std::filesystem::path model_dir_; FakeServiceBindings bindings_; @@ -55,21 +58,38 @@ class LocalModelCatalogTest : public ::testing::Test { }; TEST_F(LocalModelCatalogTest, RegisterPreservesCallerMetadataAndWritesOnlyAppDataIndex) { - auto info = MakeInfo(); + auto info = MakeMetadata(); SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, "text,image"); SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "text"); SetModelInfoIntProperty(info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 321); + SetModelInfoStringProperty(info, "model_path", "ignored"); + SetModelInfoStringProperty(info, "alias", "ignored"); + SetModelInfoStringProperty(info, "version", "ignored"); + SetModelInfoIntProperty(info, "model_path", 99); + SetModelInfoIntProperty(info, "alias", 99); + SetModelInfoIntProperty(info, "_local_registration_id", 99); + SetModelInfoIntProperty(info, "version", 99); - auto* model = catalog_.RegisterModel(info); + auto* model = catalog_.RegisterModel(model_dir_.string(), "my-model:7", info); ASSERT_NE(model, nullptr); - EXPECT_EQ(model->Id(), "my-model:0"); + EXPECT_EQ(model->Id(), "my-model:7"); + EXPECT_EQ(model->Info().name, "my-model"); + EXPECT_EQ(model->Info().alias, "my-model"); + EXPECT_EQ(model->Info().version, 7); EXPECT_EQ(model->Info().task, "chat-completion"); EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, std::string{}), "text,image"); EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, std::string{}), "text"); EXPECT_EQ(model->Info().GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, int64_t{-1}), 321); + EXPECT_EQ(model->Info().GetPropertyStr("model_path"), nullptr); + EXPECT_EQ(model->Info().GetPropertyStr("alias"), nullptr); + EXPECT_EQ(model->Info().GetPropertyStr("version"), nullptr); + EXPECT_EQ(model->Info().GetPropertyInt("model_path"), nullptr); + EXPECT_EQ(model->Info().GetPropertyInt("alias"), nullptr); + EXPECT_EQ(model->Info().GetPropertyInt("_local_registration_id"), nullptr); + EXPECT_EQ(model->Info().GetPropertyInt("version"), nullptr); EXPECT_TRUE(model->IsCached()); EXPECT_FALSE(std::filesystem::exists(model_dir_ / "model_metadata.yml")); @@ -77,41 +97,109 @@ TEST_F(LocalModelCatalogTest, RegisterPreservesCallerMetadataAndWritesOnlyAppDat ASSERT_TRUE(std::filesystem::exists(index_path)); nlohmann::json index; std::ifstream(index_path) >> index; + EXPECT_EQ(index["version"], 2); ASSERT_EQ(index["models"].size(), 1u); - EXPECT_EQ(index["models"][0]["model_id"], "my-model:0"); + EXPECT_EQ(index["models"][0]["model_id"], "my-model:7"); + EXPECT_TRUE(index["models"][0].contains("registration_id")); + EXPECT_FALSE(index["models"][0].contains("alias")); + EXPECT_FALSE(index["models"][0]["properties"].contains("model_path")); + EXPECT_FALSE(index["models"][0]["properties"].contains("alias")); + EXPECT_FALSE(index["models"][0]["properties"].contains("version")); EXPECT_FALSE(index["models"][0].contains("metadata_prepared")); } TEST_F(LocalModelCatalogTest, RegistrationRequiresExistingDirectoryAndParseableConfig) { - auto missing = MakeInfo(); - SetModelInfoStringProperty(missing, FOUNDRY_LOCAL_REG_MODEL_PATH, (root_.path() / "missing").string()); - EXPECT_THROW(catalog_.RegisterModel(missing), Exception); + EXPECT_THROW(catalog_.RegisterModel((root_.path() / "missing").string(), "missing:1", MakeMetadata()), Exception); const auto no_config_path = root_.path() / "no-config"; std::filesystem::create_directories(no_config_path); - auto no_config = MakeInfo("no-config"); - SetModelInfoStringProperty(no_config, FOUNDRY_LOCAL_REG_MODEL_PATH, no_config_path.string()); - EXPECT_THROW(catalog_.RegisterModel(no_config), Exception); + EXPECT_THROW(catalog_.RegisterModel(no_config_path.string(), "no-config:1", MakeMetadata()), Exception); const auto malformed_path = root_.path() / "malformed"; std::filesystem::create_directories(malformed_path); std::ofstream(malformed_path / "genai_config.json") << R"({"model":)"; - auto malformed = MakeInfo("malformed"); - SetModelInfoStringProperty(malformed, FOUNDRY_LOCAL_REG_MODEL_PATH, malformed_path.string()); - EXPECT_THROW(catalog_.RegisterModel(malformed), Exception); + EXPECT_THROW(catalog_.RegisterModel(malformed_path.string(), "malformed:1", MakeMetadata()), Exception); } TEST_F(LocalModelCatalogTest, RegistrationRequiresSupportedTask) { ModelInfo missing_task; - SetModelInfoStringProperty(missing_task, FOUNDRY_LOCAL_REG_MODEL_PATH, model_dir_.string()); - SetModelInfoStringProperty(missing_task, FOUNDRY_LOCAL_REG_ALIAS, "missing-task"); - EXPECT_THROW(catalog_.RegisterModel(missing_task), Exception); - EXPECT_THROW(catalog_.RegisterModel(MakeInfo("invalid-task", "text-generation")), Exception); + EXPECT_THROW(catalog_.RegisterModel(model_dir_.string(), "missing-task:1", missing_task), Exception); + EXPECT_THROW(catalog_.RegisterModel(model_dir_.string(), "invalid-task:1", MakeMetadata("text-generation")), + Exception); +} + +TEST_F(LocalModelCatalogTest, RegistrationRequiresCanonicalModelId) { + const std::vector invalid_ids = { + "", "missing-version", ":1", "model:", "model:1:2", + "model:-1", "model:+1", "model:01", "model:2147483648", + "model/path:1", "model name:1", + }; + + for (const auto& model_id : invalid_ids) { + EXPECT_THROW(catalog_.RegisterModel(model_dir_.string(), model_id, MakeMetadata()), Exception) << model_id; + } +} + +TEST_F(LocalModelCatalogTest, RegistrationUsesUniqueIdsAndGroupsVersionsByDerivedAlias) { + auto* first = Register("my-model:1"); + ASSERT_NE(first, nullptr); + EXPECT_THROW(Register("my-model:1"), Exception); + + auto* second = Register("my-model:2"); + ASSERT_NE(second, nullptr); + EXPECT_EQ(second->Info().version, 2); + EXPECT_TRUE(first->IsActive()); + EXPECT_NO_THROW(first->Download()); + + auto* grouped = catalog_.GetModel("my-model"); + ASSERT_NE(grouped, nullptr); + EXPECT_EQ(grouped->Variants().size(), 2u); + EXPECT_EQ(grouped->Id(), "my-model:2"); + EXPECT_NE(catalog_.GetModelVariant("my-model:1"), nullptr); + EXPECT_EQ(catalog_.GetModelVariant("my-model:2"), second); + + grouped->SelectVariant(*first); + EXPECT_EQ(grouped->Id(), "my-model:1"); + auto other = MakeCatalog(); + ASSERT_NE(other.RegisterModel(model_dir_.string(), "my-model:3", MakeMetadata()), nullptr); + ASSERT_EQ(catalog_.ListModels().size(), 1u); + EXPECT_EQ(grouped->Variants().size(), 3u); + EXPECT_EQ(grouped->Id(), "my-model:1"); + EXPECT_TRUE(first->IsActive()); + + catalog_.UnregisterModel("my-model:1"); + EXPECT_FALSE(first->IsActive()); + EXPECT_EQ(catalog_.GetModelVariant("my-model:1"), nullptr); + EXPECT_EQ(catalog_.GetModelVariant("my-model:2"), second); + EXPECT_TRUE(second->IsActive()); + EXPECT_NO_THROW(second->Download()); +} + +TEST_F(LocalModelCatalogTest, RefreshDefersVariantReconciliationDuringUnregister) { + Register("my-model:1"); + auto* grouped = catalog_.GetModel("my-model"); + ASSERT_NE(grouped, nullptr); + + auto other = MakeCatalog(); + grouped->BeginUnregister(); + size_t variants_during_unregister = 0; + try { + EXPECT_NE(other.RegisterModel(model_dir_.string(), "my-model:2", MakeMetadata()), nullptr); + variants_during_unregister = catalog_.ListModels().front()->Variants().size(); + } catch (...) { + grouped->CancelUnregister(); + throw; + } + grouped->CancelUnregister(); + + EXPECT_EQ(variants_during_unregister, 1u); + ASSERT_EQ(catalog_.ListModels().size(), 1u); + EXPECT_EQ(grouped->Variants().size(), 2u); } TEST_F(LocalModelCatalogTest, UnregisterPersistsAndPreservesAssets) { - auto* stale = catalog_.RegisterModel(MakeInfo()); - catalog_.UnregisterModel("my-model:0"); + auto* stale = Register(); + catalog_.UnregisterModel("my-model:1"); EXPECT_TRUE(catalog_.ListModels().empty()); EXPECT_TRUE(std::filesystem::exists(model_dir_ / "genai_config.json")); @@ -121,8 +209,22 @@ TEST_F(LocalModelCatalogTest, UnregisterPersistsAndPreservesAssets) { EXPECT_TRUE(restored.ListModels().empty()); } +TEST_F(LocalModelCatalogTest, AliasHandleRemainsSafeAfterItsFinalVariantIsUnregisteredById) { + Register(); + auto* alias_handle = catalog_.GetModel("my-model"); + ASSERT_NE(alias_handle, nullptr); + + catalog_.UnregisterModel("my-model:1"); + + EXPECT_FALSE(alias_handle->IsActive()); + EXPECT_FALSE(alias_handle->IsLoaded()); + EXPECT_EQ(alias_handle->Info().model_id, "my-model:1"); + EXPECT_TRUE(alias_handle->Variants().empty()); + EXPECT_NO_THROW(alias_handle->Unload()); +} + TEST_F(LocalModelCatalogTest, UnregisterWriteFailureLeavesModelActiveAndUsable) { - auto* model = catalog_.RegisterModel(MakeInfo()); + auto* model = Register(); ASSERT_NE(model, nullptr); const auto temp_index_path = root_.path() / "appdata" / "catalogs" / "local" / "local_models.json.tmp"; @@ -130,7 +232,7 @@ TEST_F(LocalModelCatalogTest, UnregisterWriteFailureLeavesModelActiveAndUsable) EXPECT_THROW(catalog_.UnregisterModel("my-model"), Exception); EXPECT_TRUE(model->IsActive()); - EXPECT_EQ(catalog_.GetModelVariant("my-model:0"), model); + EXPECT_EQ(catalog_.GetModelVariant("my-model:1"), model); float progress = 0.0f; EXPECT_NO_THROW(model->Download([&progress](float value) { @@ -144,7 +246,7 @@ TEST_F(LocalModelCatalogTest, TwoCatalogsReconcileRegisterUnregisterAndReregiste auto second = MakeCatalog(); EXPECT_TRUE(second.ListModels().empty()); - auto* stale = catalog_.RegisterModel(MakeInfo()); + auto* stale = Register(); ASSERT_NE(stale, nullptr); ASSERT_EQ(second.ListModels().size(), 1u); @@ -155,11 +257,47 @@ TEST_F(LocalModelCatalogTest, TwoCatalogsReconcileRegisterUnregisterAndReregiste EXPECT_THROW(stale->Load(), Exception); EXPECT_THROW(stale->RemoveFromCache(), Exception); - auto* replacement = second.RegisterModel(MakeInfo()); + auto* replacement = second.RegisterModel(model_dir_.string(), "my-model:1", MakeMetadata()); ASSERT_NE(replacement, nullptr); EXPECT_NE(replacement->RuntimeId(), stale->RuntimeId()); ASSERT_EQ(catalog_.ListModels().size(), 1u); - EXPECT_NE(catalog_.GetModelVariant("my-model:0"), stale); + EXPECT_NE(catalog_.GetModelVariant("my-model:1"), stale); +} + +TEST_F(LocalModelCatalogTest, LoadsLegacyRegistrationPropertiesUsingPersistedModelId) { + const auto catalog_dir = root_.path() / "appdata" / "catalogs" / "local"; + std::filesystem::create_directories(catalog_dir); + const nlohmann::json legacy_index = { + {"version", 1}, + {"catalog_name", "local"}, + {"models", + {{{"alias", "legacy-wrong-alias"}, + {"model_id", "legacy-model:4"}, + {"model_path", model_dir_.string()}, + {"properties", + {{"_local_registration_id", "legacy-registration"}, + {"alias", "legacy-wrong-alias"}, + {"model_path", model_dir_.string()}, + {"version", 99}, + {FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"}}}}}}, + }; + std::ofstream(catalog_dir / "local_models.json") << legacy_index.dump(2); + + auto restored = MakeCatalog(); + auto* model = restored.GetModelVariant("legacy-model:4"); + ASSERT_NE(model, nullptr); + EXPECT_EQ(model->Info().alias, "legacy-model"); + EXPECT_EQ(model->Info().version, 4); + EXPECT_EQ(model->Info().GetPropertyStr("model_path"), nullptr); + EXPECT_EQ(model->Info().GetPropertyStr("alias"), nullptr); + EXPECT_EQ(model->Info().GetPropertyInt("version"), nullptr); + + ASSERT_NE(restored.RegisterModel(model_dir_.string(), "new-model:1", MakeMetadata()), nullptr); + nlohmann::json migrated_index; + std::ifstream(catalog_dir / "local_models.json") >> migrated_index; + EXPECT_EQ(migrated_index["version"], 2); + ASSERT_EQ(migrated_index["models"].size(), 2u); + EXPECT_NE(restored.GetModelVariant("legacy-model:4"), nullptr); } TEST_F(LocalModelCatalogTest, PublicCatalogContractRejectsRegistration) { @@ -178,7 +316,7 @@ TEST_F(LocalModelCatalogTest, PublicCatalogContractRejectsRegistration) { std::string name_ = "public"; } catalog; - EXPECT_THROW(catalog.RegisterModel(MakeInfo()), Exception); + EXPECT_THROW(catalog.RegisterModel(model_dir_.string(), "my-model:1", MakeMetadata()), Exception); } } // namespace diff --git a/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc b/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc index 309a4852b..94d2e0911 100644 --- a/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc +++ b/sdk_v2/cpp/test/sdk_api/bring_your_own_model_e2e_test.cc @@ -59,7 +59,7 @@ std::optional GetByomSourceModelPath() { void StageModelAssets(const fs::path& source, const fs::path& destination) { for (const auto& entry : fs::recursive_directory_iterator(source)) { const auto relative_path = fs::relative(entry.path(), source); - if (relative_path.filename() == "inference_model.json" || relative_path.filename() == "model_metadata.yml") { + if (relative_path.filename() == "inference_model.json") { continue; } @@ -83,9 +83,10 @@ void StageModelAssets(const fs::path& source, const fs::path& destination) { class LocalRegistrationGuard { public: - LocalRegistrationGuard(foundry_local::ICatalog& catalog, std::unique_ptr model, - std::string alias) - : catalog_(catalog), model_(std::move(model)), alias_(std::move(alias)) {} + LocalRegistrationGuard(foundry_local::ICatalog& catalog, + std::unique_ptr model, + std::string model_id) + : catalog_(catalog), model_(std::move(model)), model_id_(std::move(model_id)) {} ~LocalRegistrationGuard() { try { @@ -97,7 +98,7 @@ class LocalRegistrationGuard { model_.reset(); try { - catalog_.UnregisterModel(alias_); + catalog_.UnregisterModel(model_id_); } catch (...) { } } @@ -107,7 +108,7 @@ class LocalRegistrationGuard { private: foundry_local::ICatalog& catalog_; std::unique_ptr model_; - std::string alias_; + std::string model_id_; }; } // namespace @@ -128,28 +129,35 @@ TEST(ByomE2eTest, RegisterModelPreservesAssetsAndRunsChatInference) { ASSERT_TRUE(fs::exists(staged_model_path / "genai_config.json")); ASSERT_FALSE(fs::exists(staged_model_path / "inference_model.json")); - ASSERT_FALSE(fs::exists(staged_model_path / "model_metadata.yml")); auto& local_catalog = SharedTestEnv::Get().manager()->GetCatalog(CatalogType::Local); const auto registration_alias = temp_root.path().filename().string(); - ModelInfo registration; - registration.SetStringProperty(FOUNDRY_LOCAL_REG_MODEL_PATH, staged_model_path.string().c_str()); - registration.SetStringProperty(FOUNDRY_LOCAL_REG_ALIAS, registration_alias.c_str()); - registration.SetStringProperty(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"); - - LocalRegistrationGuard registered(local_catalog, local_catalog.RegisterModel(registration), registration_alias); + const auto registration_id = registration_alias + ":1"; + ModelInfo metadata; + metadata.SetStringProperty(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"); + + LocalRegistrationGuard registered( + local_catalog, local_catalog.RegisterModel(staged_model_path.string(), registration_id, metadata), + registration_id); + const auto sibling_id = registration_alias + ":2"; + ModelInfo sibling_metadata; + sibling_metadata.SetStringProperty(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, "chat-completion"); + LocalRegistrationGuard registered_sibling( + local_catalog, local_catalog.RegisterModel(staged_model_path.string(), sibling_id, sibling_metadata), sibling_id); auto& model = registered.model(); + EXPECT_EQ(model.GetInfo().Id(), registration_id); EXPECT_EQ(model.GetInfo().Alias(), registration_alias); + EXPECT_EQ(model.GetInfo().Version(), 1); EXPECT_EQ(model.GetInfo().Task(), "chat-completion"); EXPECT_TRUE(model.IsCached()); EXPECT_FALSE(model.IsLoaded()); EXPECT_TRUE(fs::is_regular_file(staged_model_path / "genai_config.json")); - EXPECT_FALSE(fs::exists(staged_model_path / "model_metadata.yml")); EXPECT_FALSE(fs::exists(staged_model_path / "inference_model.json")); model.Load(); ASSERT_TRUE(model.IsLoaded()); + EXPECT_THROW(local_catalog.UnregisterModel(registration_alias), Error); ChatSession session(model); Request request{ From 8843b9ae6c6db1618bafa0a54c92a62086679a7e Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:39:49 -0700 Subject: [PATCH 12/17] Extract reusable UTC timestamp utility Move UTC timestamp formatting into a shared utility, reuse it for local model registration and cross-process lock metadata, and add focused unit tests. --- sdk_v2/cpp/CMakeLists.txt | 1 + sdk_v2/cpp/src/catalog/local_model_catalog.cc | 20 ++------------ .../platform/posix/cross_process_file_lock.cc | 10 ++----- .../windows/cross_process_file_lock.cc | 10 ++----- sdk_v2/cpp/src/util/time_utils.cc | 27 +++++++++++++++++++ sdk_v2/cpp/src/util/time_utils.h | 13 +++++++++ sdk_v2/cpp/test/CMakeLists.txt | 1 + .../cpp/test/internal_api/time_utils_test.cc | 21 +++++++++++++++ 8 files changed, 69 insertions(+), 34 deletions(-) create mode 100644 sdk_v2/cpp/src/util/time_utils.cc create mode 100644 sdk_v2/cpp/src/util/time_utils.h create mode 100644 sdk_v2/cpp/test/internal_api/time_utils_test.cc diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index 1f4876c16..00d2e9e33 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -271,6 +271,7 @@ set(FOUNDRY_LOCAL_SOURCES src/util/path_safety.cc src/util/region_fallback.cc src/util/sha256.cc + src/util/time_utils.cc src/util/zip_extract.cc ${FOUNDRY_LOCAL_PLATFORM_SOURCES} ${FOUNDRY_LOCAL_INTERNAL_HEADERS} diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index efe194beb..90856382a 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -5,6 +5,7 @@ #include "exception.h" #include "inferencing/generative/genai_config.h" #include "util/file_lock.h" +#include "util/time_utils.h" #include #include @@ -12,11 +13,8 @@ #include #include #include -#include #include -#include #include -#include #include #ifdef _WIN32 @@ -41,20 +39,6 @@ const std::unordered_set kSupportedTasks = { "vision-language-chat", }; -std::string UtcTimestamp(int64_t unix_time) { - const auto 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) { return std::any_of(path.begin(), path.end(), [](const auto& component) { return component == ".."; }); } @@ -290,7 +274,7 @@ ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const st const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); - SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, UtcTimestamp(now)); + SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, FormatUtcTimestamp(now)); return resolved; } diff --git a/sdk_v2/cpp/src/platform/posix/cross_process_file_lock.cc b/sdk_v2/cpp/src/platform/posix/cross_process_file_lock.cc index 4d32a69f3..f939fc4ab 100644 --- a/sdk_v2/cpp/src/platform/posix/cross_process_file_lock.cc +++ b/sdk_v2/cpp/src/platform/posix/cross_process_file_lock.cc @@ -6,13 +6,11 @@ #include "platform/cross_process_file_lock.h" #include "exception.h" #include "logger.h" +#include "util/time_utils.h" #include #include -#include -#include -#include #include #include @@ -31,11 +29,7 @@ constexpr const char* kLockFileName = ".download.lock"; std::string FormatProcessInfo() { auto pid = static_cast(getpid()); auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); - std::tm tm{}; - gmtime_r(&t, &tm); - std::ostringstream oss; - oss << "PID:" << pid << ",Time:" << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ") << '\n'; - return oss.str(); + return "PID:" + std::to_string(pid) + ",Time:" + FormatUtcTimestamp(t) + '\n'; } } // namespace diff --git a/sdk_v2/cpp/src/platform/windows/cross_process_file_lock.cc b/sdk_v2/cpp/src/platform/windows/cross_process_file_lock.cc index 6f5e44cf2..2f9cb2fae 100644 --- a/sdk_v2/cpp/src/platform/windows/cross_process_file_lock.cc +++ b/sdk_v2/cpp/src/platform/windows/cross_process_file_lock.cc @@ -6,13 +6,11 @@ #include "platform/cross_process_file_lock.h" #include "exception.h" #include "logger.h" +#include "util/time_utils.h" #include #include -#include -#include -#include #include #define WIN32_LEAN_AND_MEAN @@ -30,11 +28,7 @@ constexpr const char* kLockFileName = ".download.lock"; std::string FormatProcessInfo() { auto pid = static_cast(_getpid()); auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); - std::tm tm{}; - gmtime_s(&tm, &t); - std::ostringstream oss; - oss << "PID:" << pid << ",Time:" << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ") << '\n'; - return oss.str(); + return "PID:" + std::to_string(pid) + ",Time:" + FormatUtcTimestamp(t) + '\n'; } } // namespace diff --git a/sdk_v2/cpp/src/util/time_utils.cc b/sdk_v2/cpp/src/util/time_utils.cc new file mode 100644 index 000000000..043dfc988 --- /dev/null +++ b/sdk_v2/cpp/src/util/time_utils.cc @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "util/time_utils.h" + +#include +#include + +namespace fl { + +std::string FormatUtcTimestamp(std::time_t unix_time) { + std::tm utc{}; +#ifdef _WIN32 + if (gmtime_s(&utc, &unix_time) != 0) { + return {}; + } +#else + if (!gmtime_r(&unix_time, &utc)) { + return {}; + } +#endif + + std::ostringstream stream; + stream << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); + return stream.str(); +} + +} // namespace fl \ No newline at end of file diff --git a/sdk_v2/cpp/src/util/time_utils.h b/sdk_v2/cpp/src/util/time_utils.h new file mode 100644 index 000000000..85c1c8bfe --- /dev/null +++ b/sdk_v2/cpp/src/util/time_utils.h @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include + +namespace fl { + +/// Formats a Unix timestamp as an ISO 8601 UTC timestamp. Returns an empty string if conversion fails. +std::string FormatUtcTimestamp(std::time_t unix_time); + +} // namespace fl \ No newline at end of file diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index f7b92710a..cfd9f6f16 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -55,6 +55,7 @@ add_executable(foundry_local_tests internal_api/azure_catalog_test.cc internal_api/telemetry_test.cc internal_api/tensor_test.cc + internal_api/time_utils_test.cc internal_api/chat/search_options_test.cc internal_api/toolcalling/tool_call_stream_accumulator_test.cc internal_api/toolcalling/tool_call_utils_test.cc diff --git a/sdk_v2/cpp/test/internal_api/time_utils_test.cc b/sdk_v2/cpp/test/internal_api/time_utils_test.cc new file mode 100644 index 000000000..c80b72356 --- /dev/null +++ b/sdk_v2/cpp/test/internal_api/time_utils_test.cc @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "util/time_utils.h" + +#include + +#include + +namespace fl { +namespace { + +TEST(TimeUtilsTest, FormatsUnixEpochInUtc) { + EXPECT_EQ(FormatUtcTimestamp(std::time_t{0}), "1970-01-01T00:00:00Z"); +} + +TEST(TimeUtilsTest, FormatsNonzeroTimestampInUtc) { + EXPECT_EQ(FormatUtcTimestamp(static_cast(946684800)), "2000-01-01T00:00:00Z"); +} + +} // namespace +} // namespace fl \ No newline at end of file From e3f0edb465c3a4779593bfdff39b6483ab8749c3 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:26:59 -0700 Subject: [PATCH 13/17] Resolved several comments --- .../include/foundry_local/foundry_local_c.h | 12 +- .../include/foundry_local/foundry_local_cpp.h | 14 ++- sdk_v2/cpp/src/c_api.cc | 4 +- sdk_v2/cpp/src/catalog/local_model_catalog.cc | 88 ++++++++++++--- sdk_v2/cpp/src/model.cc | 6 +- sdk_v2/cpp/src/model.h | 10 +- sdk_v2/cpp/src/model_info.cc | 106 +++++++----------- sdk_v2/cpp/src/model_info.h | 14 +-- sdk_v2/cpp/test/internal_api/c_api_test.cc | 21 +++- .../internal_api/local_model_catalog_test.cc | 64 +++++++---- .../cpp/test/internal_api/model_info_test.cc | 14 +++ 11 files changed, 218 insertions(+), 135 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 a61506e33..f5c4b9287 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -957,8 +957,9 @@ struct flCatalogApi { /// Returned string is owned by the catalog and valid for the catalog's lifetime. FL_API_STATUS(GetName, _In_ const flCatalog* catalog, _Out_ const char** out_name); - // Catalog owns model list. Cached for efficiency. - // Models are mutable for load/unload/remove operations. Model info is immutable though. + /// The caller owns each returned model list and must release it with ModelList_Release. The model handles in a list + /// are borrowed from the catalog and remain address-valid until the owning manager is destroyed. Releasing a list + /// does not invalidate its model handles. Models are mutable for load/unload/remove operations; model info is immutable. FL_API_STATUS(GetModels, _In_ const flCatalog* catalog, _Outptr_ flModelList** out_models); FL_API_STATUS(GetModel, _In_ const flCatalog* catalog, _In_ const char* alias, _Outptr_ flModel** out_model); @@ -996,6 +997,9 @@ struct flCatalogApi { _In_ const char* model_id, _In_ const flModelInfo* metadata, _Outptr_ flModel** out_model); /// Unregister by alias or model ID without deleting model assets. + /// Future catalog queries exclude the registration, but outstanding model handles and their immutable metadata remain + /// valid until the owning manager is destroyed. Operations that require the retired registration, including Download + /// and Load, return FOUNDRY_LOCAL_ERROR_INVALID_USAGE; query and cleanup operations such as Unload remain valid. FL_API_STATUS(UnregisterModel, _In_ flCatalog* catalog, _In_ const char* alias_or_model_id); // End V2 @@ -1015,8 +1019,8 @@ struct flModelApi { /* Model handle operations. Catalog owns Model instances. */ FL_API_STATUS(IsCached, _In_ const flModel* model, _Out_ int* out_cached); - /// Returned path string is owned by the model and valid until the model is released or its cache state - /// changes via RemoveFromCache. + /// Returned path string is owned by the model and valid until the owning manager is destroyed or the model's cache + /// state changes via Download or RemoveFromCache. FL_API_STATUS(GetPath, _In_ const flModel* model, _Out_ const char** out_path); FL_API_STATUS(Download, _In_ flModel* model, _In_opt_ flProgressCallback callback, _In_opt_ void* user_data); 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 96f3a4f96..5f85e3b49 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -715,6 +715,9 @@ class IModel { // Model — concrete IModel implementation using composition // =========================================================================== +/// Non-owning wrapper over a catalog-owned model. The Manager that supplied the catalog must outlive this object and +/// any ModelInfo view obtained from it. Unregistering a local model removes it from future catalog queries without +/// invalidating existing wrappers; operations that require the retired registration, including Download and Load, fail. class Model final : public IModel { public: /// Mutable construction (from catalog lookups that return flModel*). @@ -755,6 +758,8 @@ class Model final : public IModel { // ModelList // =========================================================================== +/// Owning wrapper for a native model-list allocation. Its Model entries are non-owning views into catalog storage, so +/// the Manager that supplied the catalog must outlive the list and any Model wrapper retained from it. class ModelList { public: ModelList(flModelList& model_list); @@ -797,9 +802,8 @@ class ICatalog { /// returns every variant. `max_versions` selects the latest X versions per /// variant name (defaults to 50, matching the web service contract); pass 0 /// or a negative value for no per-variant cap. Each call performs a fresh - /// query and the returned model handles remain valid until the next - /// GetModelVersions call for the same alias or until the catalog is destroyed. - /// Queries for different aliases do not invalidate each other's results. + /// query and the returned model handles remain valid until the owning Manager + /// is destroyed. Repeated queries do not invalidate earlier results. virtual ModelList GetModelVersions(const std::string& model_alias, const std::string& variant_name = {}, int max_versions = 50) = 0; @@ -809,6 +813,8 @@ class ICatalog { virtual std::unique_ptr RegisterModel(const std::string&, const std::string&, const ModelInfo&) { throw Error("models can only be registered in a local catalog", FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); } + /// Unregister without deleting model assets. Existing model wrappers and immutable metadata views remain valid, but + /// operations that require the retired registration, including Download and Load, fail with invalid usage. virtual void UnregisterModel(const std::string&) { throw Error("models can only be unregistered from a local catalog", FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); } @@ -820,7 +826,7 @@ class ICatalog { class Catalog final : public ICatalog { public: - /// Adopt an already-created catalog handle (owning). + /// Wrap an already-created manager-owned catalog handle (non-owning). /// Most users should obtain a catalog via Manager::GetCatalog() rather than constructing one directly. explicit Catalog(flCatalog& catalog) : handle_(&catalog) {} diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index afe8eda66..59189637c 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -1026,7 +1026,7 @@ FL_API_STATUS_IMPL(Info_SetStringPropertyImpl, flModelInfo* info, const char* ke if (!info || !key || !value) { return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); } - fl::SetModelInfoStringProperty(*AsImpl(info), key, value); + AsImpl(info)->SetPropertyStr(key, value); return nullptr; API_IMPL_END } @@ -1036,7 +1036,7 @@ FL_API_STATUS_IMPL(Info_SetIntPropertyImpl, flModelInfo* info, const char* key, if (!info || !key) { return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); } - fl::SetModelInfoIntProperty(*AsImpl(info), key, value); + AsImpl(info)->SetPropertyInt(key, value); return nullptr; API_IMPL_END } diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.cc b/sdk_v2/cpp/src/catalog/local_model_catalog.cc index 90856382a..c5490968d 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.cc @@ -86,14 +86,53 @@ void RemoveLegacyRegistrationProperties(ModelInfo& info) { info.int_properties.erase(kLegacyVersionProperty); } +bool IsLegacyIntegerProperty(std::string_view key) { + return 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_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; +} + +ModelInfo ModelInfoFromLegacyPropertyBagJson(const nlohmann::json& json) { + if (!json.is_object()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "legacy model info properties must contain an object"); + } + + ModelInfo info; + for (const auto& [key, value] : json.items()) { + if (value.is_string()) { + auto text = value.get(); + if (IsLegacyIntegerProperty(key)) { + int64_t integer = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), integer); + if (error != std::errc{} || end != text.data() + text.size()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "invalid legacy integer model info property: " + key); + } + + info.SetPropertyInt(key, integer); + } else { + info.SetPropertyStr(key, std::move(text)); + } + } else if (value.is_number_integer()) { + info.SetPropertyInt(key, value.get()); + } else { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + "legacy model info property values must be strings or integers"); + } + } + + return info; +} + nlohmann::json RegistrationToJson(const LocalModelCatalog::Registration& registration) { return { - {"model_id", registration.info.model_id}, + {"model_info", ModelInfoToJson(registration.info)}, {"model_path", registration.model_path}, {"registration_id", registration.registration_id}, - {"registered_at", - registration.info.GetPropertyWithDefault(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, std::string{})}, - {"properties", ModelInfoToPropertyBagJson(registration.info)}, }; } @@ -244,12 +283,12 @@ void LocalModelCatalog::UnregisterModel(const std::string& alias_or_model_id) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "unregistered model was not present in the in-memory catalog"); } - model->CancelUnregister(); + model->EndUnregister(); unregister_in_progress = false; ListModels(); } catch (...) { if (unregister_in_progress) { - model->CancelUnregister(); + model->EndUnregister(); } throw; } @@ -266,15 +305,15 @@ ModelInfo LocalModelCatalog::ResolveMetadata(const ModelInfo& metadata, const st resolved.model_id = model_id; resolved.uri.clear(); if (!resolved.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR)) { - SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR, "local"); + resolved.SetPropertyStr(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"); + resolved.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR, "LocalRegistration"); + resolved.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_ENTITY_TYPE_STR, "Model"); + resolved.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR, "ONNX"); const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); - SetModelInfoIntProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); - SetModelInfoStringProperty(resolved, FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, FormatUtcTimestamp(now)); + resolved.SetPropertyInt(FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT, now); + resolved.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_CREATION_TIME_STR, FormatUtcTimestamp(now)); return resolved; } @@ -291,19 +330,35 @@ std::vector LocalModelCatalog::LoadRegistration stream >> root; const auto schema_version = root.value("version", 0); if (!root.is_object() || (schema_version != 1 && schema_version != 2) || !root.contains("models") || - !root["models"].is_array()) { + !root["models"].is_array()) { logger_.Log(LogLevel::Warning, "Ignoring malformed local model registration index: " + index_path_.string()); return {}; } for (const auto& item : root["models"]) { try { - if (!item.is_object() || !item.contains("model_id") || !item["model_id"].is_string() || - !item.contains("model_path") || !item["model_path"].is_string() || !item.contains("properties")) { + if (!item.is_object() || !item.contains("model_path") || !item["model_path"].is_string()) { continue; } - auto info = ModelInfoFromPropertyBagJson(item["properties"]); + ModelInfo info; + std::string model_id; + if (schema_version == 2) { + if (!item.contains("model_info") || !item["model_info"].is_object()) { + continue; + } + + info = ModelInfoFromJson(item["model_info"]); + model_id = info.model_id; + } else { + if (!item.contains("model_id") || !item["model_id"].is_string() || !item.contains("properties")) { + continue; + } + + info = ModelInfoFromLegacyPropertyBagJson(item["properties"]); + model_id = item["model_id"].get(); + } + std::string registration_id; if (item.contains("registration_id") && item["registration_id"].is_string()) { registration_id = item["registration_id"].get(); @@ -314,7 +369,6 @@ std::vector LocalModelCatalog::LoadRegistration continue; } - const auto model_id = item["model_id"].get(); const auto parsed_id = ParseModelId(model_id); std::filesystem::path model_path = item["model_path"].get(); if (model_path.empty() || HasParentTraversal(model_path)) { diff --git a/sdk_v2/cpp/src/model.cc b/sdk_v2/cpp/src/model.cc index 7624ebb09..5de98bce1 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -591,7 +591,7 @@ void Model::BeginUnregister() { } } catch (...) { while (locked_count > 0) { - unregistering_variants_[--locked_count]->CancelUnregister(); + unregistering_variants_[--locked_count]->EndUnregister(); } unregistering_variants_.clear(); unregistering_ = false; @@ -610,10 +610,10 @@ void Model::BeginUnregister() { unregistering_ = true; } -void Model::CancelUnregister() { +void Model::EndUnregister() { if (!unregistering_variants_.empty()) { for (auto* variant : unregistering_variants_) { - variant->CancelUnregister(); + variant->EndUnregister(); } unregistering_variants_.clear(); unregistering_ = false; diff --git a/sdk_v2/cpp/src/model.h b/sdk_v2/cpp/src/model.h index 5439d996e..37890672b 100644 --- a/sdk_v2/cpp/src/model.h +++ b/sdk_v2/cpp/src/model.h @@ -123,10 +123,6 @@ class Model { bool IsCached() const; bool IsLoaded() const; - bool IsActive() const { - Model* selected = selected_variant_.load(std::memory_order_acquire); - return selected ? selected->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. @@ -159,9 +155,9 @@ class Model { /// Mark this model and its variants inactive while retaining pointer validity. void Deactivate(); - /// Serialize unregister with Load(); CancelUnregister releases the lock after success or rollback. + /// Serialize unregister with Load(); EndUnregister releases the lock after success or rollback. void BeginUnregister(); - void CancelUnregister(); + void EndUnregister(); /// Select a specific variant within this container. Throws if the variant is /// not part of this model, or if this is a leaf. @@ -199,6 +195,8 @@ class Model { // flips false), so any reader that gates on IsCached() observes a complete path. std::unique_ptr info_; std::atomic cached_{false}; + // Logical tombstone state. Retired models remain allocated so outstanding catalog-owned handles stay address-valid, + // but operations that would use the registration reject them. std::atomic active_{true}; std::string local_path_; std::string runtime_model_id_; diff --git a/sdk_v2/cpp/src/model_info.cc b/sdk_v2/cpp/src/model_info.cc index 324ec85de..25a2ceb9d 100644 --- a/sdk_v2/cpp/src/model_info.cc +++ b/sdk_v2/cpp/src/model_info.cc @@ -1,7 +1,6 @@ // 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 @@ -109,6 +108,24 @@ ModelInfo ModelInfoFromJson(const nlohmann::json& j) { info.detected_region = j["detectedRegion"].get(); } + // Preserve the complete extensible ModelInfo state in addition to the catalog-compatible named fields below. + // Keeping string and integer properties separate retains their types even when both maps contain the same key. + if (j.contains("stringProperties") && j["stringProperties"].is_object()) { + for (const auto& [key, value] : j["stringProperties"].items()) { + if (value.is_string()) { + info.SetPropertyStr(key, value.get()); + } + } + } + + if (j.contains("intProperties") && j["intProperties"].is_object()) { + for (const auto& [key, value] : j["intProperties"].items()) { + if (value.is_number_integer()) { + info.SetPropertyInt(key, value.get()); + } + } + } + // String properties — named top-level fields → string_properties map ReadStringProp(j, "providerType", info.string_properties, FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR); ReadStringProp(j, "modelType", info.string_properties, FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR); @@ -211,6 +228,22 @@ nlohmann::json ModelInfoToJson(const ModelInfo& info) { j["detectedRegion"] = info.detected_region; } + if (!info.string_properties.empty()) { + nlohmann::json properties = nlohmann::json::object(); + for (const auto& [key, value] : info.string_properties) { + properties[key] = value; + } + j["stringProperties"] = std::move(properties); + } + + if (!info.int_properties.empty()) { + nlohmann::json properties = nlohmann::json::object(); + for (const auto& [key, value] : info.int_properties) { + properties[key] = value; + } + j["intProperties"] = std::move(properties); + } + // providerType — required in C#, defaults to empty const auto* provider = info.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR); j["providerType"] = provider ? *provider : ""; @@ -345,77 +378,20 @@ nlohmann::json ModelInfoToJson(const ModelInfo& info) { return j; } -void SetModelInfoStringProperty(ModelInfo& info, std::string key, std::string value) { +void ModelInfo::SetPropertyStr(std::string key, std::string value) { if (key == FOUNDRY_LOCAL_MODEL_PROP_TASK_STR) { - info.task = value; + task = value; } else if (key == FOUNDRY_LOCAL_MODEL_PROP_EP_STR) { - info.execution_provider = value; + execution_provider = value; } else if (key == FOUNDRY_LOCAL_MODEL_PROP_DEVICE_TYPE_STR) { - info.device_type = DeviceTypeFromString(value); + device_type = DeviceTypeFromString(value); } - info.string_properties[std::move(key)] = std::move(value); + string_properties[std::move(key)] = std::move(value); } -void SetModelInfoIntProperty(ModelInfo& info, std::string key, int64_t 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_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_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 ModelInfo::SetPropertyInt(std::string key, int64_t value) { + int_properties[std::move(key)] = value; } } // namespace fl diff --git a/sdk_v2/cpp/src/model_info.h b/sdk_v2/cpp/src/model_info.h index 10357352e..adea98dce 100644 --- a/sdk_v2/cpp/src/model_info.h +++ b/sdk_v2/cpp/src/model_info.h @@ -55,6 +55,12 @@ struct ModelInfo { std::map> string_properties; std::map> int_properties; + /// Set a string property while keeping typed fields synchronized with well-known keys. + void SetPropertyStr(std::string key, std::string value); + + /// Set an int property. + void SetPropertyInt(std::string key, int64_t value); + /// Look up a string property by key, returning nullptr if missing. const std::string* GetPropertyStr(std::string_view key) const { auto it = string_properties.find(key); @@ -87,12 +93,4 @@ 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); - } // namespace fl 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 44ea4601b..f4f87eb78 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -289,8 +289,8 @@ TEST(CApiTest, LocalCatalogRegistersListsAndUnregistersWithoutOwningAssets) { flModelList* models = nullptr; ASSERT_FL_OK(api, catalog_api->GetModels(catalog, &models)); ASSERT_EQ(api->ModelList_Size(models), 1u); - EXPECT_NE(api->ModelList_GetAt(models, 0), nullptr); - api->ModelList_Release(models); + flModel* listed = api->ModelList_GetAt(models, 0); + ASSERT_NE(listed, nullptr); StatusGuard remove_status{model_api->RemoveFromCache(registered), api}; ASSERT_NE(remove_status.s, nullptr); @@ -299,6 +299,23 @@ TEST(CApiTest, LocalCatalogRegistersListsAndUnregistersWithoutOwningAssets) { ASSERT_FL_OK(api, catalog_api->UnregisterModel(catalog, "c-api-model")); EXPECT_TRUE(std::filesystem::exists(model_path / "genai_config.json")); EXPECT_FALSE(std::filesystem::exists(model_path / "model_metadata.yml")); + + ASSERT_FL_OK(api, model_api->GetInfo(registered, ®istered_info)); + EXPECT_STREQ(model_api->Info_GetId(registered_info), "c-api-model:3"); + const flModelInfo* listed_info = nullptr; + ASSERT_FL_OK(api, model_api->GetInfo(listed, &listed_info)); + EXPECT_STREQ(model_api->Info_GetId(listed_info), "c-api-model:3"); + + StatusGuard registered_load_status{model_api->Load(registered), api}; + ASSERT_NE(registered_load_status.s, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(registered_load_status.s), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + StatusGuard listed_load_status{model_api->Load(listed), api}; + ASSERT_NE(listed_load_status.s, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(listed_load_status.s), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + ASSERT_FL_OK(api, model_api->Unload(registered)); + ASSERT_FL_OK(api, model_api->Unload(listed)); + api->ModelList_Release(models); + ASSERT_FL_OK(api, catalog_api->GetModels(catalog, &models)); EXPECT_EQ(api->ModelList_Size(models), 0u); api->ModelList_Release(models); 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 72becd818..3fee99b04 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 @@ -43,7 +43,7 @@ class LocalModelCatalogTest : public ::testing::Test { ModelInfo MakeMetadata(std::string task = "chat-completion") const { ModelInfo info; - SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, std::move(task)); + info.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR, std::move(task)); return info; } @@ -59,16 +59,20 @@ class LocalModelCatalogTest : public ::testing::Test { TEST_F(LocalModelCatalogTest, RegisterPreservesCallerMetadataAndWritesOnlyAppDataIndex) { auto info = MakeMetadata(); - SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, "text,image"); - SetModelInfoStringProperty(info, FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "text"); - SetModelInfoIntProperty(info, FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 321); - SetModelInfoStringProperty(info, "model_path", "ignored"); - SetModelInfoStringProperty(info, "alias", "ignored"); - SetModelInfoStringProperty(info, "version", "ignored"); - SetModelInfoIntProperty(info, "model_path", 99); - SetModelInfoIntProperty(info, "alias", 99); - SetModelInfoIntProperty(info, "_local_registration_id", 99); - SetModelInfoIntProperty(info, "version", 99); + info.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR, "text,image"); + info.SetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR, "text"); + info.SetPropertyInt(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT, 321); + info.SetPropertyStr("custom_metadata", "preserved"); + info.SetPropertyInt("custom_count", 42); + info.prompt_templates.Add("user", "<|user|>{Content}<|end|>"); + info.model_settings.Add("temperature", "0.5"); + info.SetPropertyStr("model_path", "ignored"); + info.SetPropertyStr("alias", "ignored"); + info.SetPropertyStr("version", "ignored"); + info.SetPropertyInt("model_path", 99); + info.SetPropertyInt("alias", 99); + info.SetPropertyInt("_local_registration_id", 99); + info.SetPropertyInt("version", 99); auto* model = catalog_.RegisterModel(model_dir_.string(), "my-model:7", info); @@ -99,13 +103,27 @@ TEST_F(LocalModelCatalogTest, RegisterPreservesCallerMetadataAndWritesOnlyAppDat std::ifstream(index_path) >> index; EXPECT_EQ(index["version"], 2); ASSERT_EQ(index["models"].size(), 1u); - EXPECT_EQ(index["models"][0]["model_id"], "my-model:7"); + ASSERT_TRUE(index["models"][0].contains("model_info")); + EXPECT_EQ(index["models"][0]["model_info"]["id"], "my-model:7"); + EXPECT_EQ(index["models"][0]["model_info"]["name"], "my-model"); + EXPECT_EQ(index["models"][0]["model_info"]["version"], 7); + EXPECT_EQ(index["models"][0]["model_info"]["alias"], "my-model"); + EXPECT_EQ(index["models"][0]["model_info"]["stringProperties"]["custom_metadata"], "preserved"); + EXPECT_EQ(index["models"][0]["model_info"]["intProperties"]["custom_count"], 42); EXPECT_TRUE(index["models"][0].contains("registration_id")); + EXPECT_FALSE(index["models"][0].contains("model_id")); EXPECT_FALSE(index["models"][0].contains("alias")); - EXPECT_FALSE(index["models"][0]["properties"].contains("model_path")); - EXPECT_FALSE(index["models"][0]["properties"].contains("alias")); - EXPECT_FALSE(index["models"][0]["properties"].contains("version")); + EXPECT_FALSE(index["models"][0].contains("properties")); + EXPECT_FALSE(index["models"][0].contains("registered_at")); EXPECT_FALSE(index["models"][0].contains("metadata_prepared")); + + auto restored = MakeCatalog(); + auto* restored_model = restored.GetModelVariant("my-model:7"); + ASSERT_NE(restored_model, nullptr); + EXPECT_EQ(restored_model->Info().GetPropertyWithDefault("custom_metadata", std::string{}), "preserved"); + EXPECT_EQ(restored_model->Info().GetPropertyWithDefault("custom_count", int64_t{-1}), 42); + EXPECT_STREQ(restored_model->Info().prompt_templates.Find("user"), "<|user|>{Content}<|end|>"); + EXPECT_STREQ(restored_model->Info().model_settings.Find("temperature"), "0.5"); } TEST_F(LocalModelCatalogTest, RegistrationRequiresExistingDirectoryAndParseableConfig) { @@ -148,7 +166,6 @@ TEST_F(LocalModelCatalogTest, RegistrationUsesUniqueIdsAndGroupsVersionsByDerive auto* second = Register("my-model:2"); ASSERT_NE(second, nullptr); EXPECT_EQ(second->Info().version, 2); - EXPECT_TRUE(first->IsActive()); EXPECT_NO_THROW(first->Download()); auto* grouped = catalog_.GetModel("my-model"); @@ -165,13 +182,12 @@ TEST_F(LocalModelCatalogTest, RegistrationUsesUniqueIdsAndGroupsVersionsByDerive ASSERT_EQ(catalog_.ListModels().size(), 1u); EXPECT_EQ(grouped->Variants().size(), 3u); EXPECT_EQ(grouped->Id(), "my-model:1"); - EXPECT_TRUE(first->IsActive()); + EXPECT_EQ(catalog_.GetModelVariant("my-model:1"), first); catalog_.UnregisterModel("my-model:1"); - EXPECT_FALSE(first->IsActive()); EXPECT_EQ(catalog_.GetModelVariant("my-model:1"), nullptr); + EXPECT_THROW(first->Download(), Exception); EXPECT_EQ(catalog_.GetModelVariant("my-model:2"), second); - EXPECT_TRUE(second->IsActive()); EXPECT_NO_THROW(second->Download()); } @@ -187,10 +203,10 @@ TEST_F(LocalModelCatalogTest, RefreshDefersVariantReconciliationDuringUnregister EXPECT_NE(other.RegisterModel(model_dir_.string(), "my-model:2", MakeMetadata()), nullptr); variants_during_unregister = catalog_.ListModels().front()->Variants().size(); } catch (...) { - grouped->CancelUnregister(); + grouped->EndUnregister(); throw; } - grouped->CancelUnregister(); + grouped->EndUnregister(); EXPECT_EQ(variants_during_unregister, 1u); ASSERT_EQ(catalog_.ListModels().size(), 1u); @@ -216,10 +232,10 @@ TEST_F(LocalModelCatalogTest, AliasHandleRemainsSafeAfterItsFinalVariantIsUnregi catalog_.UnregisterModel("my-model:1"); - EXPECT_FALSE(alias_handle->IsActive()); EXPECT_FALSE(alias_handle->IsLoaded()); EXPECT_EQ(alias_handle->Info().model_id, "my-model:1"); EXPECT_TRUE(alias_handle->Variants().empty()); + EXPECT_THROW(alias_handle->Load(), Exception); EXPECT_NO_THROW(alias_handle->Unload()); } @@ -231,7 +247,6 @@ TEST_F(LocalModelCatalogTest, UnregisterWriteFailureLeavesModelActiveAndUsable) std::filesystem::create_directory(temp_index_path); EXPECT_THROW(catalog_.UnregisterModel("my-model"), Exception); - EXPECT_TRUE(model->IsActive()); EXPECT_EQ(catalog_.GetModelVariant("my-model:1"), model); float progress = 0.0f; @@ -252,7 +267,6 @@ TEST_F(LocalModelCatalogTest, TwoCatalogsReconcileRegisterUnregisterAndReregiste second.UnregisterModel("my-model"); EXPECT_TRUE(catalog_.ListModels().empty()); - EXPECT_FALSE(stale->IsActive()); EXPECT_THROW(stale->Download(), Exception); EXPECT_THROW(stale->Load(), Exception); EXPECT_THROW(stale->RemoveFromCache(), Exception); @@ -297,6 +311,8 @@ TEST_F(LocalModelCatalogTest, LoadsLegacyRegistrationPropertiesUsingPersistedMod std::ifstream(catalog_dir / "local_models.json") >> migrated_index; EXPECT_EQ(migrated_index["version"], 2); ASSERT_EQ(migrated_index["models"].size(), 2u); + EXPECT_TRUE(migrated_index["models"][0].contains("model_info")); + EXPECT_FALSE(migrated_index["models"][0].contains("properties")); EXPECT_NE(restored.GetModelVariant("legacy-model:4"), nullptr); } 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 2ddf2680f..0082ca349 100644 --- a/sdk_v2/cpp/test/internal_api/model_info_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_info_test.cc @@ -111,6 +111,9 @@ TEST(ModelInfoRoundTrip, AllMetadataFieldsSurviveRoundTrip) { original.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR] = "0.5.0"; original.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR] = "FoundryLocal"; original.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR] = "ONNX"; + original.string_properties[FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR] = "text,image"; + original.string_properties["custom_property"] = "custom value"; + original.string_properties["shared_property"] = "string value"; // Reasoning fields original.string_properties[FOUNDRY_LOCAL_MODEL_PROP_REASONING_START_STR] = ""; @@ -125,6 +128,9 @@ TEST(ModelInfoRoundTrip, AllMetadataFieldsSurviveRoundTrip) { // Int properties original.int_properties[FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT] = 4096; original.int_properties[FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT] = 8192; + original.int_properties[FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT] = 32768; + original.int_properties["custom_count"] = 42; + original.int_properties["shared_property"] = 7; // Prompt templates original.prompt_templates.Add("system", "<|system|>\n{Content}<|end|>"); @@ -136,6 +142,8 @@ TEST(ModelInfoRoundTrip, AllMetadataFieldsSurviveRoundTrip) { // Round-trip nlohmann::json j = ModelInfoToJson(original); + EXPECT_EQ(j["stringProperties"]["custom_property"], "custom value"); + EXPECT_EQ(j["intProperties"]["custom_count"], 42); ModelInfo restored = ModelInfoFromJson(j); // Core identity @@ -157,6 +165,9 @@ TEST(ModelInfoRoundTrip, AllMetadataFieldsSurviveRoundTrip) { EXPECT_EQ(restored.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR), "0.5.0"); EXPECT_EQ(restored.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR), "FoundryLocal"); EXPECT_EQ(restored.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR), "ONNX"); + EXPECT_EQ(restored.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR), "text,image"); + EXPECT_EQ(restored.string_properties.at("custom_property"), "custom value"); + EXPECT_EQ(restored.string_properties.at("shared_property"), "string value"); // Reasoning fields EXPECT_EQ(restored.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_REASONING_INT), 1); @@ -171,6 +182,9 @@ TEST(ModelInfoRoundTrip, AllMetadataFieldsSurviveRoundTrip) { // Int properties EXPECT_EQ(restored.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT), 4096); EXPECT_EQ(restored.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT), 8192); + EXPECT_EQ(restored.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT), 32768); + EXPECT_EQ(restored.int_properties.at("custom_count"), 42); + EXPECT_EQ(restored.int_properties.at("shared_property"), 7); // Prompt templates ASSERT_FALSE(restored.prompt_templates.empty()); From 43db1a07729bc556acf4b6a6362a404db7892278 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:55:00 -0700 Subject: [PATCH 14/17] resolve the comments --- .../include/foundry_local/foundry_local_cpp.h | 8 +++++-- .../foundry_local/foundry_local_cpp.inline.h | 8 +++---- .../internal_api/model_load_manager_test.cc | 24 +++++++++---------- 3 files changed, 22 insertions(+), 18 deletions(-) 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 5f85e3b49..61d790b33 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -908,10 +908,14 @@ class Manager { bool IsShutdownRequested() const; private: + struct CatalogCollection { + mutable std::unique_ptr public_; + mutable std::unique_ptr local_; + }; + detail::Base handle_; Configuration config_; - mutable std::unique_ptr catalog_; - mutable std::unique_ptr local_catalog_; + CatalogCollection catalogs_; 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 57724208f..b9045a5b2 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 @@ -204,9 +204,9 @@ inline ICatalog& Manager::GetCatalog() const { std::call_once(*catalog_once_, [this]() { flCatalog* cat = nullptr; Check(detail::api()->Manager_GetCatalog(handle_.get(), &cat)); - catalog_ = std::unique_ptr(new Catalog(*cat)); + catalogs_.public_ = std::unique_ptr(new Catalog(*cat)); }); - return *catalog_; + return *catalogs_.public_; } inline ICatalog& Manager::GetCatalog(CatalogType type) const { @@ -217,9 +217,9 @@ inline ICatalog& Manager::GetCatalog(CatalogType type) const { 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); + catalogs_.local_ = std::make_unique(*catalog); }); - return *local_catalog_; + return *catalogs_.local_; } inline void Manager::StartWebService() { diff --git a/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc b/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc index 4ed5e1ea7..9a525b863 100644 --- a/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_load_manager_test.cc @@ -144,7 +144,7 @@ TEST(ModelLoadManagerTest, LoadCudaGpuModel_CudaNotAvailable_ErrorMessage) { } } -TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaNotAvailable_Throws) { +TEST(ModelLoadManagerTest, LoadRuntimeIdWithCudaConfig_CudaNotAvailable_Throws) { CpuOnlyDetector ep; fl::StderrLogger logger; fl::ModelLoadManager mgr(ep, logger); @@ -152,7 +152,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaNotAvailable_Throws) { TempModelDir dir("alias-cuda-config", "cuda"); try { - mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + mgr.LoadModel(dir.path(), "local/test-registration"); FAIL() << "Expected exception"; } catch (const fl::Exception& e) { EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); @@ -160,7 +160,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaNotAvailable_Throws) { } } -TEST(ModelLoadManagerTest, LoadAliasWithWebGpuConfig_WebGpuNotAvailable_Throws) { +TEST(ModelLoadManagerTest, LoadRuntimeIdWithWebGpuConfig_WebGpuNotAvailable_Throws) { CpuOnlyDetector ep; fl::StderrLogger logger; fl::ModelLoadManager mgr(ep, logger); @@ -168,7 +168,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithWebGpuConfig_WebGpuNotAvailable_Throws) TempModelDir dir("alias-webgpu-config", "WebGPU"); try { - mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + mgr.LoadModel(dir.path(), "local/test-registration"); FAIL() << "Expected exception"; } catch (const fl::Exception& e) { EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); @@ -176,7 +176,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithWebGpuConfig_WebGpuNotAvailable_Throws) } } -TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaAvailable_PreparesCuda) { +TEST(ModelLoadManagerTest, LoadRuntimeIdWithCudaConfig_CudaAvailable_PreparesCuda) { GpuEpDetector ep; fl::StderrLogger logger; fl::ModelLoadManager mgr(ep, logger); @@ -184,7 +184,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaAvailable_PreparesCuda) { TempModelDir dir("alias-cuda-available", "cuda"); try { - mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + mgr.LoadModel(dir.path(), "local/test-registration"); } catch (const fl::Exception& e) { EXPECT_NE(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); } @@ -192,7 +192,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithCudaConfig_CudaAvailable_PreparesCuda) { EXPECT_EQ(ep.prepared_ep, "CUDAExecutionProvider"); } -TEST(ModelLoadManagerTest, LoadAliasWithCanonicalWinMlProvider_NotAvailable_Throws) { +TEST(ModelLoadManagerTest, LoadRuntimeIdWithCanonicalWinMlProvider_NotAvailable_Throws) { CpuOnlyDetector ep; fl::StderrLogger logger; fl::ModelLoadManager mgr(ep, logger); @@ -200,7 +200,7 @@ TEST(ModelLoadManagerTest, LoadAliasWithCanonicalWinMlProvider_NotAvailable_Thro TempModelDir dir("alias-migraphx-config", "MIGraphXExecutionProvider"); try { - mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + mgr.LoadModel(dir.path(), "local/test-registration"); FAIL() << "Expected exception"; } catch (const fl::Exception& e) { EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); @@ -208,15 +208,15 @@ TEST(ModelLoadManagerTest, LoadAliasWithCanonicalWinMlProvider_NotAvailable_Thro } } -TEST(ModelLoadManagerTest, LoadAliasWithDmlConfig_DoesNotRequireDownloadableEp) { +TEST(ModelLoadManagerTest, LoadRuntimeIdWithFullDmlProviderName_DoesNotRequireDownloadableEp) { CpuOnlyDetector ep; fl::StderrLogger logger; fl::ModelLoadManager mgr(ep, logger); - TempModelDir dir("alias-dml-config", "dml"); + TempModelDir dir("alias-dml-config", "DmlExecutionProvider"); try { - mgr.LoadModel(dir.path(), "local/arbitrary-alias:0"); + mgr.LoadModel(dir.path(), "local/test-registration"); } catch (const fl::Exception& e) { EXPECT_NE(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_USAGE); } @@ -232,7 +232,7 @@ TEST(ModelLoadManagerTest, LoadWithUnknownOverride_ThrowsInvalidArgument) { TempModelDir dir("unknown-override"); try { - mgr.LoadModel(dir.path(), "local/arbitrary-alias:0", fl::ExecutionProvider::kUnknown); + mgr.LoadModel(dir.path(), "local/test-registration", fl::ExecutionProvider::kUnknown); FAIL() << "Expected exception"; } catch (const fl::Exception& e) { EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); From 729e08a5fe2a5470ad6aa22d0a78cc867087d419 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:15:06 -0700 Subject: [PATCH 15/17] Resolve "A constructed model should never have missing metadata" --- sdk_v2/cpp/src/model.cc | 58 +++++----- sdk_v2/cpp/src/model.h | 20 +++- sdk_v2/cpp/test/CMakeLists.txt | 1 + .../internal_api/model_construction_test.cc | 105 ++++++++++++++++++ 4 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 sdk_v2/cpp/test/internal_api/model_construction_test.cc diff --git a/sdk_v2/cpp/src/model.cc b/sdk_v2/cpp/src/model.cc index 5de98bce1..fae2a405d 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -105,6 +105,29 @@ bool CompareModelsForSort(const Model& m1, const Model& m2) { Model::~Model() = default; +Model::Model(ModelInfo info, + std::string local_path, + DownloadManager& download_manager, + ModelLoadManager& model_load_manager, + std::string runtime_model_id, + bool external_registration) + : info_(std::make_unique(std::move(info))), + cached_(!local_path.empty()), + local_path_(std::move(local_path)), + runtime_model_id_(std::move(runtime_model_id)), + external_registration_(external_registration), + download_manager_(&download_manager), + model_load_manager_(&model_load_manager) {} + +Model::Model(ContainerTag, Model first_variant) { + if (!first_variant.info_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "MakeContainer requires an initialized leaf Model"); + } + + variants_.push_back(std::make_unique(std::move(first_variant))); + selected_variant_.store(variants_.back().get(), std::memory_order_release); +} + Model::Model(Model&& other) noexcept : info_(std::move(other.info_)), cached_(other.cached_.load()), @@ -156,18 +179,9 @@ Model Model::FromModelInfo(ModelInfo info, std::string local_path, DownloadManager& download_manager, ModelLoadManager& model_load_manager) { - Model model; - model.runtime_model_id_ = info.model_id; - model.PublishInfo(std::move(info)); - model.download_manager_ = &download_manager; - model.model_load_manager_ = &model_load_manager; - - if (!local_path.empty()) { - model.cached_ = true; - model.local_path_ = std::move(local_path); - } - - return model; + auto runtime_model_id = info.model_id; + return Model(std::move(info), std::move(local_path), download_manager, model_load_manager, + std::move(runtime_model_id), false); } Model Model::FromLocalRegistration(ModelInfo info, @@ -175,10 +189,8 @@ Model Model::FromLocalRegistration(ModelInfo info, DownloadManager& download_manager, ModelLoadManager& model_load_manager, std::string runtime_model_id) { - auto model = FromModelInfo(std::move(info), std::move(local_path), download_manager, model_load_manager); - model.external_registration_ = true; - model.runtime_model_id_ = std::move(runtime_model_id); - return model; + return Model(std::move(info), std::move(local_path), download_manager, model_load_manager, + std::move(runtime_model_id), true); } // --------------------------------------------------------------------------- @@ -186,16 +198,16 @@ Model Model::FromLocalRegistration(ModelInfo info, // --------------------------------------------------------------------------- Model Model::MakeContainer(Model first_variant) { - Model container; - container.variants_.push_back(std::make_unique(std::move(first_variant))); - container.selected_variant_.store(container.variants_.back().get(), std::memory_order_release); - return container; + return Model(ContainerTag{}, std::move(first_variant)); } void Model::AddVariant(Model variant) { if (!IsContainer()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "AddVariant called on a non-container Model; use MakeContainer first"); } + if (!variant.info_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "AddVariant requires an initialized leaf Model"); + } std::lock_guard lock(state_mutex_); @@ -372,7 +384,7 @@ const ModelInfo& Model::Info() const { } if (!info_) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "model metadata is not initialized"); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "cannot access metadata on a moved-from Model"); } return *info_; @@ -625,10 +637,6 @@ void Model::EndUnregister() { lifecycle_mutex_.unlock(); } -void Model::PublishInfo(ModelInfo info) { - info_ = std::make_unique(std::move(info)); -} - 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 37890672b..34f576aa3 100644 --- a/sdk_v2/cpp/src/model.h +++ b/sdk_v2/cpp/src/model.h @@ -28,13 +28,15 @@ class ModelLoadManager; // Model leaves that share the same alias and delegates all operations // to a selected variant. Additional variants are added via AddVariant. // -// The C API type (flModel) inherits from this with zero extra members, -// following the same pattern as flItem : fl::Item. +// The C API exposes this through the unrelated opaque flModel handle type; +// c_api_types.h provides the internal pointer conversions. // ----------------------------------------------------------------------- class Model { public: - Model() = default; + // Every successfully constructed Model is either a leaf with immutable metadata or a container with a selected, + // metadata-bearing leaf. A moved-from Model may only be destroyed or assigned a new value. + Model() = delete; ~Model(); Model(Model&& other) noexcept; Model& operator=(Model&& other) noexcept; @@ -182,9 +184,17 @@ class Model { } private: - void PublishInfo(ModelInfo info); + struct ContainerTag {}; - // Leaf data (default/empty for containers). + Model(ModelInfo info, + std::string local_path, + DownloadManager& download_manager, + ModelLoadManager& model_load_manager, + std::string runtime_model_id, + bool external_registration); + Model(ContainerTag, Model first_variant); + + // Leaf data (empty for containers). Construction guarantees this is non-null for every leaf. // cached_ is atomic — flipped concurrently by the download path. // Loaded state is NOT stored here; it is queried from ModelLoadManager so the load // manager remains the single source of truth (Manager::Shutdown clears its map without diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index cfd9f6f16..7eacdc0b7 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(foundry_local_tests internal_api/local_model_scanner_test.cc internal_api/model_info_test.cc internal_api/model_info_accessors_test.cc + internal_api/model_construction_test.cc internal_api/model_io_info_test.cc internal_api/model_load_manager_test.cc internal_api/model_sorting_test.cc diff --git a/sdk_v2/cpp/test/internal_api/model_construction_test.cc b/sdk_v2/cpp/test/internal_api/model_construction_test.cc new file mode 100644 index 000000000..6fceef0a0 --- /dev/null +++ b/sdk_v2/cpp/test/internal_api/model_construction_test.cc @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "exception.h" +#include "internal_api/test_helpers.h" +#include "model.h" +#include "model_info.h" + +#include + +#include +#include +#include + +using namespace fl; + +static_assert(!std::is_default_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); + +namespace { + +Model MakeLeaf(std::string model_id = "test-model", std::string task = "chat-completion") { + static fl::test::FakeServiceBindings svc; + ModelInfo info; + info.model_id = std::move(model_id); + info.name = "test"; + info.version = 1; + info.alias = "test-alias"; + info.task = std::move(task); + return Model::FromModelInfo(std::move(info), "", svc.download_manager, svc.model_load_manager); +} + +} // namespace + +TEST(ModelConstructionTest, LeafHasMetadataImmediately) { + auto model = MakeLeaf(); + + EXPECT_EQ(model.Info().model_id, "test-model"); + EXPECT_EQ(model.Info().alias, "test-alias"); + EXPECT_EQ(model.Info().task, "chat-completion"); +} + +TEST(ModelConstructionTest, LocalRegistrationHasMetadataImmediately) { + static fl::test::FakeServiceBindings svc; + ModelInfo info; + info.model_id = "local-model:1"; + info.name = "local-model"; + info.version = 1; + info.alias = "local-model"; + info.task = "chat-completion"; + + auto model = Model::FromLocalRegistration(std::move(info), "model-path", svc.download_manager, + svc.model_load_manager, "local/registration-id"); + + EXPECT_EQ(model.Info().model_id, "local-model:1"); + EXPECT_EQ(model.Info().task, "chat-completion"); + EXPECT_EQ(model.RuntimeId(), "local/registration-id"); +} + +TEST(ModelConstructionTest, ContainerHasSelectedMetadataImmediately) { + auto container = Model::MakeContainer(MakeLeaf()); + + ASSERT_TRUE(container.IsContainer()); + EXPECT_EQ(container.Info().model_id, "test-model"); + EXPECT_EQ(container.Info().task, "chat-completion"); +} + +TEST(ModelConstructionTest, MoveConstructionPreservesContainerMetadata) { + auto source = Model::MakeContainer(MakeLeaf()); + + Model moved(std::move(source)); + + ASSERT_TRUE(moved.IsContainer()); + EXPECT_EQ(moved.Info().model_id, "test-model"); + EXPECT_EQ(moved.Info().task, "chat-completion"); +} + +TEST(ModelConstructionTest, MoveAssignmentPreservesContainerMetadata) { + auto source = Model::MakeContainer(MakeLeaf()); + auto destination = MakeLeaf("destination-model"); + + destination = std::move(source); + + ASSERT_TRUE(destination.IsContainer()); + EXPECT_EQ(destination.Info().model_id, "test-model"); + EXPECT_EQ(destination.Info().task, "chat-completion"); +} + +TEST(ModelConstructionTest, MakeContainerRejectsContainerAsVariant) { + auto nested = Model::MakeContainer(MakeLeaf()); + + EXPECT_THROW(Model::MakeContainer(std::move(nested)), fl::Exception); +} + +TEST(ModelConstructionTest, AddVariantRejectsContainerAndPreservesExistingSelection) { + auto container = Model::MakeContainer(MakeLeaf()); + auto nested = Model::MakeContainer(MakeLeaf("nested-model")); + + EXPECT_THROW(container.AddVariant(std::move(nested)), fl::Exception); + + ASSERT_TRUE(container.IsContainer()); + EXPECT_EQ(container.Variants().size(), 1u); + EXPECT_EQ(container.Info().model_id, "test-model"); +} From 49ecc8b9747334c38fa4d3e653e7f8e7d0c1d6ea Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:42:32 -0700 Subject: [PATCH 16/17] Name local model factory arguments --- sdk_v2/cpp/src/catalog/local_model_catalog.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk_v2/cpp/src/catalog/local_model_catalog.h b/sdk_v2/cpp/src/catalog/local_model_catalog.h index 76b166432..c6ab87611 100644 --- a/sdk_v2/cpp/src/catalog/local_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/local_model_catalog.h @@ -13,7 +13,8 @@ namespace fl { /// Mutable, persistent catalog for models registered from arbitrary local directories. class LocalModelCatalog final : public BaseModelCatalog { public: - using ModelFactory = std::function; + using ModelFactory = + std::function; LocalModelCatalog(std::filesystem::path app_data_dir, ModelFactory model_factory, ILogger& logger); From 5591379581badeb62f0e7bfac759f833e0e2b053 Mon Sep 17 00:00:00 2001 From: Selena Yang <179177246+selenayang888@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:59:11 -0700 Subject: [PATCH 17/17] Fix BYOM catalog tests and Python API v2 bindings --- .../test/internal_api/azure_model_catalog_test.cc | 4 ++-- .../src/foundry_local_sdk/_native/build_cffi.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/sdk_v2/cpp/test/internal_api/azure_model_catalog_test.cc b/sdk_v2/cpp/test/internal_api/azure_model_catalog_test.cc index 07ce84419..804082124 100644 --- a/sdk_v2/cpp/test/internal_api/azure_model_catalog_test.cc +++ b/sdk_v2/cpp/test/internal_api/azure_model_catalog_test.cc @@ -232,7 +232,7 @@ TEST_F(AzureModelCatalogTest, AllUrlsFailUsesSnapshotMetadataAndScannedPathsWith EXPECT_EQ(cpu_model->Info().alias, "snapshot-alias"); EXPECT_EQ(cpu_model->LocalPath(), cpu_path.string()); - EXPECT_EQ(catalog->GetModelVariant("offline-byom:4"), nullptr); + EXPECT_EQ(FindVariant(cached_models, "offline-byom:4"), nullptr); CatalogCache persisted_cache(cache_directory_.string(), services_.logger); persisted_cache.Load(); @@ -311,7 +311,7 @@ TEST_F(AzureModelCatalogTest, LiveAggregationDeduplicatesAndSavesOnlyResolvedPub const auto models = catalog->ListModels(); ASSERT_EQ(models.size(), 2u); - EXPECT_EQ(catalog->GetModelVariant("custom-model:0"), nullptr); + EXPECT_EQ(FindVariant(models, "custom-model:0"), nullptr); EXPECT_EQ(first_behavior->fetch_by_id_calls, 1); EXPECT_EQ(second_behavior->fetch_by_id_calls, 1); diff --git a/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py b/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py index c48ec92ff..0dd85b837 100644 --- a/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py +++ b/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py @@ -79,6 +79,11 @@ FOUNDRY_LOCAL_DEVICE_NPU = 3, } flDeviceType; +typedef enum flCatalogType { + FOUNDRY_LOCAL_CATALOG_PUBLIC = 0, + FOUNDRY_LOCAL_CATALOG_LOCAL = 1, +} flCatalogType; + typedef enum flTensorDataType { FOUNDRY_LOCAL_TENSOR_UNDEFINED = 0, FOUNDRY_LOCAL_TENSOR_FLOAT = 1, @@ -389,6 +394,7 @@ _Bool (*Manager_IsEpDownloadInProgress)(const flManager* manager); flStatusPtr (*Manager_Shutdown)(flManager* manager); _Bool (*Manager_IsShutdownRequested)(const flManager* manager); + flStatusPtr (*Manager_GetCatalogByType)(const flManager* manager, flCatalogType catalog_type, flCatalog** out_catalog); } flApi; /* ----------------------------------------------------------------------- @@ -489,6 +495,8 @@ flStatusPtr (*GetCachedModels)(const flCatalog* catalog, flModelList** out_models); flStatusPtr (*GetLoadedModels)(const flCatalog* catalog, flModelList** out_models); flStatusPtr (*GetModelVersions)(const flCatalog* catalog, const char* model_alias, const char* model_name, int32_t max_versions, flModelList** out_models); + flStatusPtr (*RegisterModel)(flCatalog* catalog, const char* model_path, const char* model_id, const flModelInfo* metadata, flModel** out_model); + flStatusPtr (*UnregisterModel)(flCatalog* catalog, const char* alias_or_model_id); } flCatalogApi; /* ----------------------------------------------------------------------- @@ -519,6 +527,10 @@ const flKeyValuePairs* (*Info_GetModelSettings)(const flModelInfo* info); const char* (*Info_GetStringProperty)(const flModelInfo* info, const char* key); int64_t (*Info_GetIntProperty)(const flModelInfo* info, const char* key, int64_t default_value); + flStatusPtr (*CreateModelInfo)(flModelInfo** out_info); + void (*ReleaseModelInfo)(flModelInfo* info); + flStatusPtr (*Info_SetStringProperty)(flModelInfo* info, const char* key, const char* value); + flStatusPtr (*Info_SetIntProperty)(flModelInfo* info, const char* key, int64_t value); } flModelApi; /* -----------------------------------------------------------------------