From 02c23439d52fc394d3519d489c9be3bc566c18ac Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Tue, 28 Jul 2026 12:04:57 -0700 Subject: [PATCH 01/14] name each catalog (AddCatalog config API) Introduce named catalog sources so multiple catalogs can be separately addressed: - Add CatalogSource{name,url,filter} struct and kDefaultCatalogName="public" in configuration.h; change catalog_urls to vector. - Validate() now rejects empty catalog name and url. - New AddCatalog(name,url,filter) across the C ABI (foundry_local_c.h / c_api.cc vtable) and the C++ wrapper (foundry_local_cpp.h / .inline.h); AddCatalogUrl auto-derives the name from the URL. - manager.cc shim converts named sources back to pairs for the existing AzureModelCatalog. - Tests: add reject-empty-name; update existing configuration tests to CatalogSource. --- .../include/foundry_local/foundry_local_c.h | 9 +++++++ .../include/foundry_local/foundry_local_cpp.h | 9 +++++++ .../foundry_local/foundry_local_cpp.inline.h | 8 ++++++ sdk_v2/cpp/src/c_api.cc | 24 +++++++++++++++--- sdk_v2/cpp/src/configuration.cc | 7 ++++-- sdk_v2/cpp/src/configuration.h | 25 +++++++++++++++---- sdk_v2/cpp/src/manager.cc | 8 +++++- .../test/internal_api/configuration_test.cc | 11 ++++++-- 8 files changed, 88 insertions(+), 13 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 034348e99..600b84c8e 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -908,9 +908,18 @@ struct flConfigurationApi { FL_API_STATUS(SetModelCacheDir, _In_ flConfiguration* config, _In_ const char* dir); /// Optional. Add a catalog URL. Defaults to the Azure Foundry Local Catalog if none added. /// Multiple catalogs can be added. Catalogs priority is determined by the order they were added. + /// The catalog is registered under an auto-derived name (its URL); to give it an explicit + /// name for scoped operations, use AddCatalog instead. /// @param filter_override Optional filter string for this catalog. Pass NULL for no override. FL_API_STATUS(AddCatalogUrl, _In_ flConfiguration* config, _In_ const char* url, _In_opt_ const char* filter_override); + /// Optional. Add a named catalog. Defaults to the Azure Foundry Local Catalog if none added. + /// Multiple catalogs can be added; each must have a unique name. Priority follows add-order. + /// The name is used to address the catalog for scoped list/download operations. + /// The name "public" is reserved for the built-in default catalog. + /// @param filter_override Optional filter string for this catalog. Pass NULL for no override. + FL_API_STATUS(AddCatalog, _In_ flConfiguration* config, _In_ const char* name, _In_ const char* url, + _In_opt_ const char* filter_override); /// Optional. Azure region for the model registry download endpoint /// (https://{region}.api.azureml.ms/modelregistry/...). Resolves a model's /// asset_id to a downloadable blob storage URL. Defaults to "centralus" when not set. 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..6ffab175a 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -249,9 +249,18 @@ class Configuration { /// Optional. Add a catalog URL to connect to. /// Defaults to the Azure Foundry Local Catalog if none are added. + /// The catalog is registered under an auto-derived name (its URL); use AddCatalog to + /// assign an explicit name for scoped list/download operations. Configuration& AddCatalogUrl(const std::string& url, const std::optional& filter_override = std::nullopt); + /// Optional. Add a named catalog to connect to. + /// Defaults to the Azure Foundry Local Catalog if none are added. + /// Each catalog must have a unique name; the name is used to address the catalog for + /// scoped operations. The name "public" is reserved for the built-in default catalog. + Configuration& AddCatalog(const std::string& name, const std::string& url, + const std::optional& filter_override = std::nullopt); + /// Optional. Add an endpoint for the web service to bind to. /// Defaults to "http://127.0.0.1:0" (ephemeral port) if none are added. Configuration& AddWebServiceEndpoint(const std::string& url); 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..a66ccfb43 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 @@ -160,6 +160,14 @@ inline Configuration& Configuration::AddCatalogUrl( return *this; } +inline Configuration& Configuration::AddCatalog( + const std::string& name, const std::string& url, const std::optional& filter_override) { + Check(detail::config_api()->AddCatalog( + handle_.get_mutable(), name.c_str(), url.c_str(), + filter_override ? filter_override->c_str() : nullptr)); + return *this; +} + inline Configuration& Configuration::AddWebServiceEndpoint(const std::string& url) { Check(detail::config_api()->AddWebServiceEndpoint(handle_.get_mutable(), url.c_str())); return *this; diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index dbf49cc03..1f1c3462b 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -241,9 +241,26 @@ FL_API_STATUS_IMPL(AddCatalogUrlImpl, flConfiguration* config, const char* url, return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); } - AsImpl(config)->catalog_urls.emplace_back( - url, - filter_override ? std::optional{filter_override} : std::nullopt); + // No explicit name provided: derive the catalog name from its URL. + AsImpl(config)->catalog_urls.push_back(fl::CatalogSource{ + /*name=*/url, + /*url=*/url, + filter_override ? std::optional{filter_override} : std::nullopt}); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(AddCatalogImpl, flConfiguration* config, const char* name, const char* url, + const char* filter_override) { + API_IMPL_BEGIN + if (!config || !name || !url) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + AsImpl(config)->catalog_urls.push_back(fl::CatalogSource{ + /*name=*/name, + /*url=*/url, + filter_override ? std::optional{filter_override} : std::nullopt}); return nullptr; API_IMPL_END } @@ -305,6 +322,7 @@ static const flConfigurationApi g_configuration_api = { SetLogsDirImpl, SetModelCacheDirImpl, AddCatalogUrlImpl, + AddCatalogImpl, SetCatalogRegionImpl, AddWebServiceEndpointImpl, SetExternalServiceUrlImpl, diff --git a/sdk_v2/cpp/src/configuration.cc b/sdk_v2/cpp/src/configuration.cc index 5152dd894..d3872ba9c 100644 --- a/sdk_v2/cpp/src/configuration.cc +++ b/sdk_v2/cpp/src/configuration.cc @@ -42,10 +42,13 @@ void Configuration::Validate() { } // Validate catalog URLs are non-empty strings if present - for (const auto& [url, filter] : catalog_urls) { - if (url.empty()) { + for (const auto& source : catalog_urls) { + if (source.url.empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "Configuration: catalog URL must not be empty"); } + if (source.name.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "Configuration: catalog name must not be empty"); + } } // Validate web service endpoints are non-empty strings if present diff --git a/sdk_v2/cpp/src/configuration.h b/sdk_v2/cpp/src/configuration.h index 761fe4065..96e9ea833 100644 --- a/sdk_v2/cpp/src/configuration.h +++ b/sdk_v2/cpp/src/configuration.h @@ -12,6 +12,22 @@ namespace fl { +/// Reserved name of the built-in default (public) Azure Foundry Local catalog. +/// Used when no catalogs are explicitly added, and returned by the un-named +/// Manager::GetCatalog(). Applications must not register a catalog under this name. +inline constexpr const char* kDefaultCatalogName = "public"; + +/// A named model catalog source. +/// `name` identifies the catalog for scoped operations (list/download by catalog). +/// `url` is the catalog endpoint. `filter` is an optional per-catalog filter override; +/// `nullopt` means "use the catalog's default filter", while an empty string is a +/// distinct, valid override value. +struct CatalogSource { + std::string name; + std::string url; + std::optional filter; +}; + /// Top-level configuration for Manager. /// Mirrors the C API's flConfiguration design. struct Configuration { @@ -21,11 +37,10 @@ struct Configuration { std::optional logs_dir; LogLevel log_level = LogLevel::Warning; - /// Catalog URLs with optional per-catalog filter overrides. - /// `nullopt` filter means "use the catalog's default filter"; an empty string is a - /// distinct, valid override value. - /// Defaults to the Azure Foundry Local Catalog if empty. - std::vector>> catalog_urls; + /// Registered catalog sources, each with a unique name. + /// Defaults to a single default (public) Azure Foundry Local catalog named + /// `kDefaultCatalogName` if empty. Catalog priority follows add-order. + std::vector catalog_urls; /// Azure region for the model registry download endpoint /// (https://{catalog_region}.api.azureml.ms/modelregistry/...). diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 70e67a6a0..3918b86fb 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -327,8 +327,14 @@ 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_); + + std::vector>> catalog_url_pairs; + catalog_url_pairs.reserve(config_.catalog_urls.size()); + for (const auto& source : config_.catalog_urls) { + catalog_url_pairs.emplace_back(source.url, source.filter); + } catalog_ = std::make_unique( - config_.catalog_urls, + catalog_url_pairs, download_manager_->GetCacheDirectory(), [this](ModelInfo info, std::string local_path) { return CreateModel(std::move(info), std::move(local_path)); diff --git a/sdk_v2/cpp/test/internal_api/configuration_test.cc b/sdk_v2/cpp/test/internal_api/configuration_test.cc index eae4bb28c..47cc695bd 100644 --- a/sdk_v2/cpp/test/internal_api/configuration_test.cc +++ b/sdk_v2/cpp/test/internal_api/configuration_test.cc @@ -32,7 +32,14 @@ TEST(ConfigurationTest, DefaultValues) { TEST(ConfigurationTest, ValidateRejectsEmptyCatalogUrl) { Configuration config; config.app_name = "test_app"; - config.catalog_urls.emplace_back("", ""); + config.catalog_urls.push_back(CatalogSource{"public", "", std::string("")}); + EXPECT_THROW(config.Validate(), fl::Exception); +} + +TEST(ConfigurationTest, ValidateRejectsEmptyCatalogName) { + Configuration config; + config.app_name = "test_app"; + config.catalog_urls.push_back(CatalogSource{"", "https://example.com/catalog", std::string("")}); EXPECT_THROW(config.Validate(), fl::Exception); } @@ -46,7 +53,7 @@ TEST(ConfigurationTest, ValidateRejectsEmptyEndpoint) { TEST(ConfigurationTest, ValidateAcceptsCatalogUrlsAndEndpoints) { Configuration config; config.app_name = "test_app"; - config.catalog_urls.emplace_back("https://example.com/catalog", ""); + config.catalog_urls.push_back(CatalogSource{"public", "https://example.com/catalog", std::string("")}); config.web_service_endpoints.emplace_back("http://127.0.0.1:0"); EXPECT_NO_THROW(config.Validate()); } From 4b5ac08988a26064c1bba4fc45afa927533469df Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Tue, 28 Jul 2026 13:41:36 -0700 Subject: [PATCH 02/14] Manager holds N separately addressable catalogs Replace the single catalog_ member with an add-ordered list of named catalogs. Build one AzureModelCatalog per registered source instead of one merged catalog; when none are registered, expose the built-in Azure Foundry catalog as the default "public" catalog. - manager.h: NamedCatalog{name,catalog} vector; add GetCatalog(name) overload and ListCatalogNames(). - manager.cc: construct one catalog per source; GetCatalog() returns the first (default) catalog, GetCatalog(name) looks up by name and throws INVALID_ARGUMENT if unknown, ListCatalogNames() enumerates in add-order. Web service uses GetCatalog(); InvalidateCache loops all catalogs. Default catalog = first registered ("public" when none added). No aggregation across catalogs. --- sdk_v2/cpp/src/manager.cc | 71 ++++++++++++++++++++++++++++----------- sdk_v2/cpp/src/manager.h | 21 ++++++++++-- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 3918b86fb..1f8ae517f 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -328,21 +328,33 @@ Manager::Manager(const Configuration& config) session_manager_ = std::make_unique(*logger_); telemetry_ = std::make_unique(config_.app_name, *logger_); - std::vector>> catalog_url_pairs; - catalog_url_pairs.reserve(config_.catalog_urls.size()); - for (const auto& source : config_.catalog_urls) { - catalog_url_pairs.emplace_back(source.url, source.filter); - } - catalog_ = std::make_unique( - catalog_url_pairs, - download_manager_->GetCacheDirectory(), - [this](ModelInfo info, std::string local_path) { - return CreateModel(std::move(info), std::move(local_path)); - }, - *ep_detector_, *logger_, - config_.external_service_url.has_value(), - config_.catalog_region.value_or("auto"), - disable_region_fallback); + // Build one AzureModelCatalog per registered source (single URL each) so each + // catalog is separately addressable by name. No aggregation across catalogs. + auto make_catalog = [&](std::vector>> urls) { + return std::make_unique( + std::move(urls), + download_manager_->GetCacheDirectory(), + [this](ModelInfo info, std::string local_path) { + return CreateModel(std::move(info), std::move(local_path)); + }, + *ep_detector_, *logger_, + config_.external_service_url.has_value(), + config_.catalog_region.value_or("auto"), + disable_region_fallback); + }; + + if (config_.catalog_urls.empty()) { + // No catalogs registered: expose the built-in Azure Foundry catalog as the + // default "public" catalog. + catalogs_.push_back({std::string(kDefaultCatalogName), make_catalog({})}); + } else { + // The first registered source is the default returned by the no-arg + // GetCatalog(); the rest are reached by name. + for (const auto& source : config_.catalog_urls) { + std::vector>> single{{source.url, source.filter}}; + catalogs_.push_back({source.name, make_catalog(std::move(single))}); + } + } } Manager::~Manager() { @@ -367,7 +379,7 @@ Manager::~Manager() { session_manager_.reset(); model_load_manager_.reset(); download_manager_.reset(); - catalog_.reset(); + catalogs_.clear(); telemetry_.reset(); ep_detector_.reset(); @@ -447,7 +459,26 @@ void Manager::Destroy() { } ICatalog& Manager::GetCatalog() { - return *catalog_; + return *catalogs_.front().catalog; +} + +ICatalog& Manager::GetCatalog(const std::string& name) { + for (auto& entry : catalogs_) { + if (entry.name == name) { + return *entry.catalog; + } + } + FL_LOG_AND_THROW(*logger_, FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + fmt::format("unknown catalog: '{}'", name)); +} + +std::vector Manager::ListCatalogNames() const { + std::vector names; + names.reserve(catalogs_.size()); + for (const auto& entry : catalogs_) { + names.push_back(entry.name); + } + return names; } void Manager::StartWebService() { @@ -463,7 +494,7 @@ 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(GetCatalog(), *logger_, *config_.model_cache_dir, *model_load_manager_, *session_manager_, *telemetry_, [this]() { Shutdown(); }); @@ -585,7 +616,9 @@ 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(); + for (auto& entry : catalogs_) { + entry.catalog->InvalidateCache(); + } } return result; diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index 4b5440db7..1e60bf2eb 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -45,11 +45,19 @@ class Manager { /// Destroy the singleton and release all resources. static void Destroy(); - /// Get the shared catalog interface for querying models. + /// Get the default (public) catalog interface for querying models. /// 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(); + /// Get a registered catalog by name. Throws FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT + /// if no catalog with that name is registered. + ICatalog& GetCatalog(const std::string& name); + + /// Enumerate the names of all registered catalogs, in registration order. + /// The first name is the default catalog returned by the no-arg GetCatalog(). + std::vector ListCatalogNames() const; + /// Get the configuration used to create this manager. const Configuration& GetConfiguration() const; @@ -123,7 +131,7 @@ class Manager { // ep_detector_ — detects HW acceleration; holds OrtEnv& (must // outlive ort_env_ release in ~Manager()) // telemetry_ — used throughout - // catalog_ — owns all Model instances. used by download_manager, model_load_manager, and web service + // catalogs_ — one ICatalog per registered source; own all Model instances. used by download_manager, model_load_manager, and web service // download_manager_ — uses ModelInfo owned by catalog // model_load_manager_ — holds loaded model state referencing catalog models // session_manager_ — tracks all active sessions. destroyed after web service, before models @@ -137,7 +145,14 @@ class Manager { std::unique_ptr logger_; std::unique_ptr ep_detector_; std::unique_ptr telemetry_; - std::unique_ptr catalog_; + // Registered catalogs in registration order. The first entry is the default + // (public) catalog returned by the no-arg GetCatalog(); named entries are + // reached via GetCatalog(name). No aggregation across catalogs. + struct NamedCatalog { + std::string name; + std::unique_ptr catalog; + }; + std::vector catalogs_; std::unique_ptr download_manager_; std::unique_ptr model_load_manager_; std::unique_ptr session_manager_; From 6e3fe0f2f19e30808da68a6b50da5fd8363f62e6 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Wed, 29 Jul 2026 11:52:33 -0700 Subject: [PATCH 03/14] expose named catalogs over the C ABI Add Manager_GetCatalogByName and Manager_ListCatalogNames to the C ABI so callers can address individual registered catalogs by name. - foundry_local_c.h: append the two functions before the V1 end marker. - c_api.cc: cache per-name flCatalog wrappers on flManager so returned pointers stay valid for the manager's lifetime; back Manager_ListCatalogNames with owned string storage. Register both in the g_api_v1 vtable. Manager_GetCatalog continues to return the default catalog. --- .../include/foundry_local/foundry_local_c.h | 13 +++++ sdk_v2/cpp/src/c_api.cc | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+) 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 600b84c8e..41a49ea86 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -705,6 +705,19 @@ typedef struct flApi { /// Check if Shutdown has been called. bool FL_API_T(Manager_IsShutdownRequested, _In_ const flManager* manager); + /// Get a registered catalog by name. Returns FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT + /// if no catalog with that name is registered. The returned flCatalog is owned by + /// the Manager and remains valid for its lifetime. + FL_API_STATUS(Manager_GetCatalogByName, _In_ const flManager* manager, _In_ const char* name, + _Outptr_ flCatalog** out_catalog); + + /// Enumerate the names of all registered catalogs, in registration order. The first + /// name is the default catalog returned by Manager_GetCatalog. The returned array and + /// its strings are owned by the Manager and remain valid until the next call to + /// Manager_ListCatalogNames on the same manager or until the Manager is released. + FL_API_STATUS(Manager_ListCatalogNames, _In_ const flManager* manager, + _Outptr_result_buffer_(*out_count) const char* const** out_names, _Out_ size_t* out_count); + // End V1 /* Append new function pointers at the end for future versions and add marker for the end of each version */ } flApi; diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index 1f1c3462b..05abd7529 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -73,6 +73,12 @@ struct flManager { fl::Manager& impl; std::unique_ptr catalog; // stores the flCatalog wrapper around impl.GetCatalog() mutable std::vector urls_cache; + // flCatalog wrappers for named catalogs, created on demand by Manager_GetCatalogByName + // and owned for the lifetime of the manager so returned pointers stay valid. + mutable std::map> catalog_by_name; + // Backing storage for Manager_ListCatalogNames: owns the strings and the pointer array. + mutable std::vector catalog_names_storage; + mutable std::vector catalog_names_cache; }; // ======================================================================== @@ -580,6 +586,45 @@ static bool FL_API_CALL Manager_IsShutdownRequestedImpl(const flManager* manager return manager->impl.IsShutdownRequested(); } +FL_API_STATUS_IMPL(Manager_GetCatalogByNameImpl, const flManager* manager, const char* name, + flCatalog** out_catalog) { + API_IMPL_BEGIN + if (!manager || !name || !out_catalog) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + auto it = manager->catalog_by_name.find(name); + if (it == manager->catalog_by_name.end()) { + // Throws FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT (caught by API_IMPL_END) if unknown. + fl::ICatalog& cat = manager->impl.GetCatalog(name); + it = manager->catalog_by_name.emplace(name, std::make_unique(flCatalog{cat})).first; + } + + *out_catalog = it->second.get(); + return nullptr; + API_IMPL_END +} + +FL_API_STATUS_IMPL(Manager_ListCatalogNamesImpl, const flManager* manager, + const char* const** out_names, size_t* out_count) { + API_IMPL_BEGIN + if (!manager || !out_names || !out_count) { + return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); + } + + manager->catalog_names_storage = manager->impl.ListCatalogNames(); + manager->catalog_names_cache.clear(); + manager->catalog_names_cache.reserve(manager->catalog_names_storage.size()); + for (const auto& n : manager->catalog_names_storage) { + manager->catalog_names_cache.push_back(n.c_str()); + } + + *out_names = manager->catalog_names_cache.data(); + *out_count = manager->catalog_names_cache.size(); + return nullptr; + API_IMPL_END +} + // ======================================================================== // Catalog API // ======================================================================== @@ -1906,6 +1951,8 @@ static const flApi g_api_v1 = { Manager_IsEpDownloadInProgressImpl, Manager_ShutdownImpl, Manager_IsShutdownRequestedImpl, + Manager_GetCatalogByNameImpl, + Manager_ListCatalogNamesImpl, }; // ======================================================================== From 5982a602fc77976bad02d86a353725b66dd385ed Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Wed, 29 Jul 2026 12:11:51 -0700 Subject: [PATCH 04/14] C++ wrapper for named catalogs Add Manager::GetCatalog(name) and Manager::ListCatalogNames() to the RAII C++ wrapper, layered over the C ABI added previously. - foundry_local_cpp.h: declare both methods; cache named Catalog wrappers in a mutex-guarded map so repeated lookups return the same object. - foundry_local_cpp.inline.h: implement both over Manager_GetCatalogByName and Manager_ListCatalogNames. The no-argument GetCatalog() continues to return the default catalog. --- .../include/foundry_local/foundry_local_cpp.h | 14 ++++++++++- .../foundry_local/foundry_local_cpp.inline.h | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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 6ffab175a..44590c47a 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -838,9 +839,17 @@ class Manager { const Configuration& GetConfiguration() const { return config_; } - /// Get the catalog for querying models. Creates on first call, caches internally. + /// Get the default (public) catalog for querying models. Creates on first call, caches internally. ICatalog& GetCatalog() const; + /// Get a registered catalog by name. Throws if no catalog with that name is registered. + /// The returned catalog is cached, so repeated calls with the same name yield the same object. + ICatalog& GetCatalog(const std::string& name) const; + + /// Enumerate the names of all registered catalogs, in registration order. The first name + /// corresponds to the default catalog returned by the no-argument GetCatalog(). + std::vector ListCatalogNames() const; + /// Start the embedded web service. void StartWebService(); @@ -876,6 +885,9 @@ class Manager { Configuration config_; mutable std::unique_ptr catalog_; mutable std::unique_ptr catalog_once_{std::make_unique()}; + // Cache of named catalog wrappers, guarded by named_catalogs_mutex_ for concurrent access. + mutable std::map> named_catalogs_; + mutable std::unique_ptr named_catalogs_mutex_{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 a66ccfb43..b6889202a 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 @@ -211,6 +211,30 @@ inline ICatalog& Manager::GetCatalog() const { return *catalog_; } +inline ICatalog& Manager::GetCatalog(const std::string& name) const { + std::lock_guard lock(*named_catalogs_mutex_); + auto it = named_catalogs_.find(name); + if (it == named_catalogs_.end()) { + flCatalog* cat = nullptr; + Check(detail::api()->Manager_GetCatalogByName(handle_.get(), name.c_str(), &cat)); + it = named_catalogs_.emplace(name, std::unique_ptr(new Catalog(*cat))).first; + } + return *it->second; +} + +inline std::vector Manager::ListCatalogNames() const { + const char* const* names = nullptr; + size_t count = 0; + Check(detail::api()->Manager_ListCatalogNames(handle_.get(), &names, &count)); + + std::vector result; + result.reserve(count); + for (size_t i = 0; i < count; ++i) { + result.emplace_back(names[i]); + } + return result; +} + inline void Manager::StartWebService() { Check(detail::api()->Manager_WebServiceStart(handle_.get_mutable())); } From 81a62df7bd5dc9a3d9e043a527de7c2b01cfc1b7 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Wed, 29 Jul 2026 14:40:59 -0700 Subject: [PATCH 05/14] Add tests for named multi-catalog support Cover the named-catalog surface across the C ABI and the C++ wrapper: - C ABI (c_api_test.cc): Manager_ListCatalogNames defaults to "public", returns registered names in add-order, AddCatalogUrl auto-derives the name from the URL; Manager_GetCatalogByName resolves registered catalogs and caches handles, fails with INVALID_ARGUMENT for unknown/null names; null-argument validation for both entry points. - C++ wrapper (cpp_api_test.cc): Configuration::AddCatalog chaining, Manager::ListCatalogNames default and ordered results, GetCatalog(name) resolution vs. the default GetCatalog(), and throwing on unknown names. --- sdk_v2/cpp/test/internal_api/c_api_test.cc | 182 +++++++++++++++++++++ sdk_v2/cpp/test/sdk_api/cpp_api_test.cc | 43 +++++ 2 files changed, 225 insertions(+) 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..8480e8935 100644 --- a/sdk_v2/cpp/test/internal_api/c_api_test.cc +++ b/sdk_v2/cpp/test/internal_api/c_api_test.cc @@ -247,6 +247,188 @@ TEST(CApiTest, GetCatalogNameNullCatalogFails) { api->Status_Release(status); } +// ======================================================================== +// Named catalogs (list + lookup by name) +// ======================================================================== + +TEST(CApiTest, ListCatalogNamesDefaultsToPublic) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + const char* const* names = nullptr; + size_t count = 0; + ASSERT_FL_OK(api, api->Manager_ListCatalogNames(mgr, &names, &count)); + ASSERT_EQ(count, 1u); + EXPECT_STREQ(names[0], "public"); + + api->GetConfigurationApi()->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, ListCatalogNamesReturnsRegisteredNamesInOrder) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flConfigurationApi* config_api = api->GetConfigurationApi(); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + ASSERT_TRUE(IsOk(config_api->AddCatalog(config, "first", "https://example.com/first", nullptr))); + ASSERT_TRUE(IsOk(config_api->AddCatalog(config, "second", "https://example.com/second", nullptr))); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + const char* const* names = nullptr; + size_t count = 0; + ASSERT_FL_OK(api, api->Manager_ListCatalogNames(mgr, &names, &count)); + ASSERT_EQ(count, 2u); + EXPECT_STREQ(names[0], "first"); + EXPECT_STREQ(names[1], "second"); + + config_api->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, GetCatalogByNameResolvesRegisteredCatalog) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flConfigurationApi* config_api = api->GetConfigurationApi(); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + ASSERT_TRUE(IsOk(config_api->AddCatalog(config, "first", "https://example.com/first", nullptr))); + ASSERT_TRUE(IsOk(config_api->AddCatalog(config, "second", "https://example.com/second", nullptr))); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + flCatalog* first = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalogByName(mgr, "first", &first)); + EXPECT_NE(first, nullptr); + + flCatalog* second = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalogByName(mgr, "second", &second)); + EXPECT_NE(second, nullptr); + EXPECT_NE(first, second); + + // The no-arg GetCatalog returns the first registered catalog (the default). + flCatalog* def = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalog(mgr, &def)); + EXPECT_NE(def, nullptr); + + // Repeated lookups return the same cached handle. + flCatalog* first_again = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalogByName(mgr, "first", &first_again)); + EXPECT_EQ(first, first_again); + + config_api->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, GetCatalogByNameUnknownFails) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + flCatalog* cat = nullptr; + flStatus* status = api->Manager_GetCatalogByName(mgr, "does-not-exist", &cat); + ASSERT_NE(status, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(status), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(status); + + api->GetConfigurationApi()->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, GetCatalogByNameNullArgumentsFail) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + flCatalog* cat = nullptr; + flStatus* s1 = api->Manager_GetCatalogByName(mgr, nullptr, &cat); + ASSERT_NE(s1, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(s1), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(s1); + + flStatus* s2 = api->Manager_GetCatalogByName(mgr, "first", nullptr); + ASSERT_NE(s2, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(s2), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(s2); + + api->GetConfigurationApi()->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, ListCatalogNamesNullArgumentsFail) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + const char* const* names = nullptr; + size_t count = 0; + flStatus* s1 = api->Manager_ListCatalogNames(mgr, nullptr, &count); + ASSERT_NE(s1, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(s1), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(s1); + + flStatus* s2 = api->Manager_ListCatalogNames(mgr, &names, nullptr); + ASSERT_NE(s2, nullptr); + EXPECT_EQ(api->Status_GetErrorCode(s2), FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT); + api->Status_Release(s2); + + api->GetConfigurationApi()->Configuration_Release(config); + api->Manager_Release(mgr); +} + +TEST(CApiTest, AddCatalogUrlAutoDerivesNameFromUrl) { + const flApi* api = GetApi(); + ASSERT_NE(api, nullptr); + const flConfigurationApi* config_api = api->GetConfigurationApi(); + + flConfiguration* config = CreateTestConfig(api); + ASSERT_NE(config, nullptr); + // AddCatalogUrl registers the catalog under an auto-derived name (its URL). + ASSERT_TRUE(IsOk(config_api->AddCatalogUrl(config, "https://example.com/only", nullptr))); + + flManager* mgr = nullptr; + ASSERT_FL_OK(api, api->Manager_Create(config, &mgr)); + + const char* const* names = nullptr; + size_t count = 0; + ASSERT_FL_OK(api, api->Manager_ListCatalogNames(mgr, &names, &count)); + ASSERT_EQ(count, 1u); + EXPECT_STREQ(names[0], "https://example.com/only"); + + flCatalog* cat = nullptr; + ASSERT_FL_OK(api, api->Manager_GetCatalogByName(mgr, "https://example.com/only", &cat)); + EXPECT_NE(cat, nullptr); + + config_api->Configuration_Release(config); + api->Manager_Release(mgr); +} + TEST(CApiTest, GetCatalogNameNullOutputFails) { const flApi* api = GetApi(); ASSERT_NE(api, nullptr); diff --git a/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc b/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc index a4a1bdc46..a7776f854 100644 --- a/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc +++ b/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc @@ -335,6 +335,49 @@ TEST(CppApiTest, ConfigurationChaining) { // Should not throw — just verify chaining compiles and runs } +TEST(CppApiTest, ConfigurationAddCatalogChaining) { + // AddCatalog participates in the fluent chaining surface. + foundry_local::Configuration config("test_add_catalog"); + config.AddCatalog("first", "https://example.com/first") + .AddCatalog("second", "https://example.com/second"); + // Should not throw — just verify chaining compiles and runs. +} + +TEST(CppApiTest, ManagerListCatalogNamesDefaultsToPublic) { + foundry_local::Manager manager(foundry_local::Configuration("test_default_catalog")); + + auto names = manager.ListCatalogNames(); + ASSERT_EQ(names.size(), 1u); + EXPECT_EQ(names[0], "public"); +} + +TEST(CppApiTest, ManagerNamedCatalogsResolveByName) { + foundry_local::Configuration config("test_named_catalogs"); + config.AddCatalog("first", "https://example.com/first") + .AddCatalog("second", "https://example.com/second"); + foundry_local::Manager manager(std::move(config)); + + auto names = manager.ListCatalogNames(); + ASSERT_EQ(names.size(), 2u); + EXPECT_EQ(names[0], "first"); + EXPECT_EQ(names[1], "second"); + + // Named lookup resolves each catalog; the no-arg GetCatalog() returns the first (default). + auto& first = manager.GetCatalog("first"); + auto& second = manager.GetCatalog("second"); + EXPECT_NE(&first, &second); + EXPECT_EQ(&manager.GetCatalog(), &first); + + // Repeated lookups return the same cached wrapper. + EXPECT_EQ(&manager.GetCatalog("first"), &first); +} + +TEST(CppApiTest, ManagerGetCatalogUnknownNameThrows) { + foundry_local::Manager manager(foundry_local::Configuration("test_unknown_catalog")); + + EXPECT_THROW(manager.GetCatalog("does-not-exist"), foundry_local::Error); +} + TEST(CppApiTest, ErrorFromCode) { foundry_local::Error err("test error", FOUNDRY_LOCAL_ERROR_INTERNAL); EXPECT_EQ(err.Code(), FOUNDRY_LOCAL_ERROR_INTERNAL); From a1b20a1175bb1f4c5f64b05e9328fc6da306f0da Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Thu, 30 Jul 2026 00:56:04 -0700 Subject: [PATCH 06/14] Document named multi-catalog support Update the C++ SDK docs and public header comments to describe the separately addressable named catalogs: - CppPortGuide: rewrite the catalog-architecture note to explain N named catalogs (no aggregation/union/de-dup), the AddCatalog/AddCatalogUrl config surface, GetCatalog(name)/ListCatalogNames, and the default ("public") catalog behavior. - WrapperInterfacesDesign: mention the GetCatalog(name) and ListCatalogNames overloads. - foundry_local_c.h / foundry_local_cpp.h: clarify that the no-argument GetCatalog returns the first-registered (or built-in "public") catalog. --- sdk_v2/cpp/docs/CppPortGuide.md | 19 +++++++++++++++++-- sdk_v2/cpp/docs/WrapperInterfacesDesign.md | 3 +++ .../include/foundry_local/foundry_local_c.h | 2 ++ .../include/foundry_local/foundry_local_cpp.h | 3 ++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/sdk_v2/cpp/docs/CppPortGuide.md b/sdk_v2/cpp/docs/CppPortGuide.md index 3a3506046..2fb54ecd7 100644 --- a/sdk_v2/cpp/docs/CppPortGuide.md +++ b/sdk_v2/cpp/docs/CppPortGuide.md @@ -40,7 +40,7 @@ FoundryLocalCore (singleton, DI container) ``` Manager (singleton, explicit Create/Destroy lifecycle) -├── ICatalog (BaseModelCatalog → AzureModelCatalog, LocalModelScanner) +├── ICatalog (BaseModelCatalog → AzureModelCatalog, LocalModelScanner); N named, addressed individually ├── ModelLoadManager (mutex-guarded map>) ├── Session/ChatSession/AudioSession (stateful, owns conversation history) ├── IEpDetector (EpDetector — real detection + CUDA bootstrapping) @@ -184,7 +184,7 @@ Both are move-only / non-copyable. C# uses `IDisposable`; C++ uses RAII via `uni |----|-----|-------| | `IModelCatalog` generic interface | `ICatalog` non-generic interface | C++ drops the generic; all catalogs produce `Model` | | `BaseModelCatalog` | `BaseModelCatalog` | Same role: lazy population, indexed lookup | -| `AggregateModelCatalog` | *(not ported)* | C++ uses a single catalog with multiple sources internally | +| `AggregateModelCatalog` | *(not ported)* | C++ holds N separately addressable catalogs instead of merging them | | `CachedInfo` struct | `ModelIndex` (atomic `shared_ptr`) | C++ uses lock-free index swap for concurrent reads | | `AsyncLock` | `std::mutex` + `std::lock_guard` | Different concurrency primitives | @@ -193,6 +193,21 @@ C++ catalog uses **three indices** (by id, by alias, by name) stored in an gives lock-free reads during catalog queries. The C# version uses `AsyncLock` around reads/writes. +**Separately addressable catalogs.** Rather than merging every source into one aggregate, +the Manager holds N catalogs, each registered under a unique name and each backed by its own +`AzureModelCatalog`. Results are never unioned or de-duplicated across catalogs — you address +one catalog at a time: + +- Configure sources with `Configuration::AddCatalog(name, url, filter?)`. `AddCatalogUrl(url)` + remains supported and registers the catalog under an auto-derived name (its URL). +- `Manager::GetCatalog(name)` returns the named catalog; `Manager::ListCatalogNames()` + enumerates registered names in add-order. +- The no-argument `Manager::GetCatalog()` returns the first-registered catalog (the default). + When no source is added, the built-in Azure Foundry catalog is registered under the reserved + name `"public"`, which then serves as the default. +- The C ABI exposes this via `Manager_GetCatalogByName` and `Manager_ListCatalogNames`; an + unknown name yields `FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT`. + The C++ `Model` class has two modes: - **Leaf:** Single model variant with its own `ModelInfo` - **Container:** Multi-variant wrapper that delegates property access to a selected variant diff --git a/sdk_v2/cpp/docs/WrapperInterfacesDesign.md b/sdk_v2/cpp/docs/WrapperInterfacesDesign.md index 6134ea9c0..924bf1160 100644 --- a/sdk_v2/cpp/docs/WrapperInterfacesDesign.md +++ b/sdk_v2/cpp/docs/WrapperInterfacesDesign.md @@ -149,6 +149,9 @@ Driven by the interface decisions: ctor for the rare case where a user holds a raw `flCatalog*` from the C API and wants a wrapper directly. There is no `friend` relationship between `Manager` and `Catalog` — the public ctor is consistent with every other top-level wrapper. + `Manager::GetCatalog(name)` returns a specific named catalog and `Manager::ListCatalogNames()` + enumerates the registered names; the no-argument overload returns the first-registered + (default) catalog. - `Catalog::GetModel(alias)` and `Catalog::GetModelVariant(id)` return `std::unique_ptr`. Null = not found. - `Catalog::GetLatestVersion(const IModel&)` returns `std::unique_ptr`. 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 41a49ea86..701e621e2 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -629,6 +629,8 @@ typedef struct flApi { FL_API_STATUS(Manager_Create, _In_ const flConfiguration* config, _Outptr_ flManager** out_manager); FL_TYPE_RELEASE(Manager); + /// Get the default catalog: the first registered catalog, or the built-in "public" + /// catalog when none was added. For a specific catalog, use Manager_GetCatalogByName. FL_API_STATUS(Manager_GetCatalog, _In_ const flManager* manager, _Outptr_ flCatalog** out_catalog); FL_API_STATUS(Manager_WebServiceStart, _In_ flManager* manager); // Get the bound service urls. Returns success with *out_num_urls == 0 when the web service is not running; 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 44590c47a..d158ecaf8 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -839,7 +839,8 @@ class Manager { const Configuration& GetConfiguration() const { return config_; } - /// Get the default (public) catalog for querying models. Creates on first call, caches internally. + /// Get the default catalog for querying models. This is the first registered catalog, or the + /// built-in "public" catalog when none was added. Creates on first call, caches internally. ICatalog& GetCatalog() const; /// Get a registered catalog by name. Throws if no catalog with that name is registered. From 8f4a005b4eea5290e090ed6f6746b7026d992c92 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Thu, 30 Jul 2026 01:23:23 -0700 Subject: [PATCH 07/14] Add example demonstrating named catalogs A runnable example that links the SDK and exercises the multi-catalog surface without network access or model downloads: - default catalog behavior when no source is added (registered as "public") - registering several named catalogs and enumerating them with ListCatalogNames - resolving each catalog by name and the cached repeated-lookup behavior - the default catalog corresponding to the first registered name - the Error raised for an unknown catalog name Wired into the examples build as catalog_example. --- sdk_v2/cpp/CMakeLists.txt | 3 + sdk_v2/cpp/examples/catalog/CMakeLists.txt | 4 + sdk_v2/cpp/examples/catalog/main.cc | 90 ++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 sdk_v2/cpp/examples/catalog/CMakeLists.txt create mode 100644 sdk_v2/cpp/examples/catalog/main.cc diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index fd1c86b51..022591b74 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -478,10 +478,12 @@ if(FOUNDRY_LOCAL_BUILD_EXAMPLES) add_subdirectory(examples/tool_calling) add_subdirectory(examples/realtime_audio) add_subdirectory(examples/embeddings) + add_subdirectory(examples/catalog) set_target_properties(basic_chat_example PROPERTIES FOLDER "Examples") set_target_properties(tool_calling_example PROPERTIES FOLDER "Examples") set_target_properties(realtime_audio_example PROPERTIES FOLDER "Examples") set_target_properties(embeddings_example PROPERTIES FOLDER "Examples") + set_target_properties(catalog_example PROPERTIES FOLDER "Examples") # On Linux, GenAI does dlopen("libonnxruntime.so") internally. Modern GCC # emits RUNPATH which doesn't propagate to transitive dlopen calls. @@ -491,5 +493,6 @@ if(FOUNDRY_LOCAL_BUILD_EXAMPLES) target_link_options(tool_calling_example PRIVATE -Wl,--disable-new-dtags) target_link_options(realtime_audio_example PRIVATE -Wl,--disable-new-dtags) target_link_options(embeddings_example PRIVATE -Wl,--disable-new-dtags) + target_link_options(catalog_example PRIVATE -Wl,--disable-new-dtags) endif() endif() diff --git a/sdk_v2/cpp/examples/catalog/CMakeLists.txt b/sdk_v2/cpp/examples/catalog/CMakeLists.txt new file mode 100644 index 000000000..8e9cb30e1 --- /dev/null +++ b/sdk_v2/cpp/examples/catalog/CMakeLists.txt @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft. All rights reserved. + +add_executable(catalog_example main.cc) +target_link_libraries(catalog_example PRIVATE foundry_local_cpp) diff --git a/sdk_v2/cpp/examples/catalog/main.cc b/sdk_v2/cpp/examples/catalog/main.cc new file mode 100644 index 000000000..a8e2e3b57 --- /dev/null +++ b/sdk_v2/cpp/examples/catalog/main.cc @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Example: Multiple separately addressable catalogs. +// Demonstrates registering several named catalogs, enumerating them, resolving +// each one by name, the default-catalog behavior, and the error raised for an +// unknown name. This exercises the catalog-addressing surface without any +// network access or model download. + +#include + +#include +#include + +using namespace foundry_local; + +namespace { + +// Print the names of every catalog registered on the manager, in add-order. +void PrintCatalogNames(const Manager& manager) { + std::vector names = manager.ListCatalogNames(); + std::cout << "Registered catalogs (" << names.size() << "):\n"; + for (const auto& name : names) { + std::cout << " - " << name << "\n"; + } +} + +// Demonstrate the default catalog when no source is configured: the built-in +// Azure Foundry catalog is registered under the reserved name "public". +void DefaultCatalog() { + std::cout << "\n--- Default catalog (no sources added) ---\n"; + Manager manager(Configuration("catalog_demo_default")); + + PrintCatalogNames(manager); + + // The no-argument GetCatalog() returns the default (first-registered) catalog. + ICatalog& def = manager.GetCatalog(); + std::cout << "Default catalog resolved: " << (&def ? "yes" : "no") << "\n"; +} + +// Demonstrate several named catalogs addressed individually. +void NamedCatalogs() { + std::cout << "\n--- Named catalogs ---\n"; + + Configuration config("catalog_demo_named"); + config.AddCatalog("first", "https://example.com/first") + .AddCatalog("second", "https://example.com/second"); + Manager manager(std::move(config)); + + PrintCatalogNames(manager); + + // Resolve each catalog by name. Repeated lookups return the same object. + ICatalog& first = manager.GetCatalog("first"); + ICatalog& second = manager.GetCatalog("second"); + std::cout << "first and second are distinct: " << (&first != &second ? "yes" : "no") << "\n"; + + // The no-argument GetCatalog() returns the default catalog, which corresponds to + // the first registered name. + ICatalog& def = manager.GetCatalog(); + std::cout << "Default catalog resolved: " << (&def ? "yes" : "no") << "\n"; + std::cout << "Default corresponds to first registered name: " + << (manager.ListCatalogNames().front() == "first" ? "yes" : "no") << "\n"; + + // Repeated lookups of the same name return the same cached object. + ICatalog& first_again = manager.GetCatalog("first"); + std::cout << "Repeated GetCatalog(\"first\") is cached: " + << (&first == &first_again ? "yes" : "no") << "\n"; + + // An unknown name raises an Error. + try { + manager.GetCatalog("does-not-exist"); + std::cout << "ERROR: expected an exception for unknown catalog name\n"; + } catch (const Error& ex) { + std::cout << "Unknown name correctly rejected: " << ex.what() << "\n"; + } +} + +} // namespace + +int main() { + try { + DefaultCatalog(); + NamedCatalogs(); + std::cout << "\nDone.\n"; + } catch (const Error& ex) { + std::cerr << "Unexpected error: " << ex.what() << "\n"; + return 1; + } + return 0; +} From a1a9903e8d626b106a8e32981440fc00c7443be1 Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Thu, 30 Jul 2026 12:02:35 -0700 Subject: [PATCH 08/14] Make catalog example interactive Turn the catalog example into a small REPL for hands-on exploration of the named multi-catalog surface: - list enumerate registered catalog names - add register a named catalog (rebuilds the singleton manager) - use select the current catalog (validates the name) - name show the current catalog's reported name - models live-query the current catalog's models - help / quit The manager is a process-wide singleton, so adding a catalog destroys the existing manager before constructing a new one from the updated sources. --- sdk_v2/cpp/examples/catalog/main.cc | 181 +++++++++++++++++++--------- 1 file changed, 122 insertions(+), 59 deletions(-) diff --git a/sdk_v2/cpp/examples/catalog/main.cc b/sdk_v2/cpp/examples/catalog/main.cc index a8e2e3b57..b0625cffb 100644 --- a/sdk_v2/cpp/examples/catalog/main.cc +++ b/sdk_v2/cpp/examples/catalog/main.cc @@ -1,23 +1,57 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // -// Example: Multiple separately addressable catalogs. -// Demonstrates registering several named catalogs, enumerating them, resolving -// each one by name, the default-catalog behavior, and the error raised for an -// unknown name. This exercises the catalog-addressing surface without any -// network access or model download. +// Example: Interactive exploration of multiple separately addressable catalogs. +// +// This is a small REPL. You register named catalogs, enumerate them, select one, +// and query its models — demonstrating that each catalog is addressed and served +// independently (no aggregation across catalogs). +// +// Catalogs are configured up front (before the Manager is created), so the REPL +// rebuilds the Manager whenever you add a catalog. The `models` command performs +// a live network query against the selected catalog's URL. #include #include +#include +#include #include +#include using namespace foundry_local; namespace { -// Print the names of every catalog registered on the manager, in add-order. -void PrintCatalogNames(const Manager& manager) { +// A registered catalog source. +struct Source { + std::string name; + std::string url; +}; + +void PrintHelp() { + std::cout << + "\nCommands:\n" + " list List registered catalog names\n" + " add Register a named catalog (rebuilds the manager)\n" + " use Select a catalog as the current one\n" + " name Show the current catalog's reported name\n" + " models List models from the current catalog (live query)\n" + " help Show this help\n" + " quit Exit\n\n"; +} + +// Build a Manager from the registered sources. With no sources, the built-in +// "public" catalog is the default. +std::unique_ptr BuildManager(const std::vector& sources) { + Configuration config("catalog_repl"); + for (const auto& s : sources) { + config.AddCatalog(s.name, s.url); + } + return std::make_unique(std::move(config)); +} + +void ListCatalogs(const Manager& manager) { std::vector names = manager.ListCatalogNames(); std::cout << "Registered catalogs (" << names.size() << "):\n"; for (const auto& name : names) { @@ -25,66 +59,95 @@ void PrintCatalogNames(const Manager& manager) { } } -// Demonstrate the default catalog when no source is configured: the built-in -// Azure Foundry catalog is registered under the reserved name "public". -void DefaultCatalog() { - std::cout << "\n--- Default catalog (no sources added) ---\n"; - Manager manager(Configuration("catalog_demo_default")); - - PrintCatalogNames(manager); - - // The no-argument GetCatalog() returns the default (first-registered) catalog. - ICatalog& def = manager.GetCatalog(); - std::cout << "Default catalog resolved: " << (&def ? "yes" : "no") << "\n"; -} - -// Demonstrate several named catalogs addressed individually. -void NamedCatalogs() { - std::cout << "\n--- Named catalogs ---\n"; - - Configuration config("catalog_demo_named"); - config.AddCatalog("first", "https://example.com/first") - .AddCatalog("second", "https://example.com/second"); - Manager manager(std::move(config)); - - PrintCatalogNames(manager); - - // Resolve each catalog by name. Repeated lookups return the same object. - ICatalog& first = manager.GetCatalog("first"); - ICatalog& second = manager.GetCatalog("second"); - std::cout << "first and second are distinct: " << (&first != &second ? "yes" : "no") << "\n"; - - // The no-argument GetCatalog() returns the default catalog, which corresponds to - // the first registered name. - ICatalog& def = manager.GetCatalog(); - std::cout << "Default catalog resolved: " << (&def ? "yes" : "no") << "\n"; - std::cout << "Default corresponds to first registered name: " - << (manager.ListCatalogNames().front() == "first" ? "yes" : "no") << "\n"; - - // Repeated lookups of the same name return the same cached object. - ICatalog& first_again = manager.GetCatalog("first"); - std::cout << "Repeated GetCatalog(\"first\") is cached: " - << (&first == &first_again ? "yes" : "no") << "\n"; - - // An unknown name raises an Error. +void ListModels(Manager& manager, const std::string& current) { try { - manager.GetCatalog("does-not-exist"); - std::cout << "ERROR: expected an exception for unknown catalog name\n"; + ICatalog& catalog = current.empty() ? manager.GetCatalog() : manager.GetCatalog(current); + std::cout << "Querying '" << (current.empty() ? std::string("") : current) + << "'...\n"; + ModelList models = catalog.GetModels(); + std::cout << "Models (" << models.size() << "):\n"; + for (const auto& model : models.Models()) { + ModelInfo info = model->GetInfo(); + std::cout << " - " << info.Alias() << " (" << info.Id() << ")\n"; + } } catch (const Error& ex) { - std::cout << "Unknown name correctly rejected: " << ex.what() << "\n"; + std::cout << "Query failed: " << ex.what() << "\n"; } } } // namespace int main() { - try { - DefaultCatalog(); - NamedCatalogs(); - std::cout << "\nDone.\n"; - } catch (const Error& ex) { - std::cerr << "Unexpected error: " << ex.what() << "\n"; - return 1; + std::vector sources; + std::unique_ptr manager = BuildManager(sources); + std::string current; // empty = default catalog + + std::cout << "Interactive catalog demo. Type 'help' for commands.\n"; + ListCatalogs(*manager); + + std::string line; + while (true) { + std::cout << "\n[" << (current.empty() ? "default" : current) << "] > " << std::flush; + if (!std::getline(std::cin, line)) { + break; // EOF + } + + std::istringstream iss(line); + std::string cmd; + iss >> cmd; + + if (cmd.empty()) { + continue; + } else if (cmd == "quit" || cmd == "exit") { + break; + } else if (cmd == "help") { + PrintHelp(); + } else if (cmd == "list") { + ListCatalogs(*manager); + } else if (cmd == "add") { + std::string name, url; + iss >> name >> url; + if (name.empty() || url.empty()) { + std::cout << "Usage: add \n"; + continue; + } + try { + sources.push_back({name, url}); + manager.reset(); // Manager is a singleton — destroy the old one first. + manager = BuildManager(sources); + current.clear(); + std::cout << "Added '" << name << "'. Manager rebuilt.\n"; + ListCatalogs(*manager); + } catch (const Error& ex) { + sources.pop_back(); + manager.reset(); + manager = BuildManager(sources); + std::cout << "Failed to add catalog: " << ex.what() << "\n"; + } + } else if (cmd == "use") { + std::string name; + iss >> name; + if (name.empty()) { + std::cout << "Usage: use \n"; + continue; + } + try { + (void)manager->GetCatalog(name); // validate the name exists + current = name; + std::cout << "Current catalog: " << current << "\n"; + } catch (const Error& ex) { + std::cout << "Unknown catalog: " << ex.what() << "\n"; + } + } else if (cmd == "name") { + ICatalog& catalog = current.empty() ? manager->GetCatalog() : manager->GetCatalog(current); + std::cout << "Reported name: " << catalog.GetName() << "\n"; + } else if (cmd == "models") { + ListModels(*manager, current); + } else { + std::cout << "Unknown command: " << cmd << " (type 'help')\n"; + } } + + std::cout << "Bye.\n"; return 0; } From 5981c7ec8a61a96c5ec87da7ca3fcdcfd479007f Mon Sep 17 00:00:00 2001 From: Emmanuel <91394589+kobby-kobbs@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:16:13 -0700 Subject: [PATCH 09/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sdk_v2/cpp/src/manager.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index 1e60bf2eb..ed3402a07 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -131,7 +131,8 @@ class Manager { // ep_detector_ — detects HW acceleration; holds OrtEnv& (must // outlive ort_env_ release in ~Manager()) // telemetry_ — used throughout - // catalogs_ — one ICatalog per registered source; own all Model instances. used by download_manager, model_load_manager, and web service + // catalogs_ — one ICatalog per registered source; owns all Model instances. Used by + // download_manager, model_load_manager, and web service // download_manager_ — uses ModelInfo owned by catalog // model_load_manager_ — holds loaded model state referencing catalog models // session_manager_ — tracks all active sessions. destroyed after web service, before models From b63552b5ce5c57057aa8f77d9aecd41bdb899cd9 Mon Sep 17 00:00:00 2001 From: Emmanuel <91394589+kobby-kobbs@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:16:30 -0700 Subject: [PATCH 10/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h | 1 + 1 file changed, 1 insertion(+) 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 b6889202a..b35d0d2c4 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 @@ -223,6 +223,7 @@ inline ICatalog& Manager::GetCatalog(const std::string& name) const { } inline std::vector Manager::ListCatalogNames() const { + std::lock_guard lock(*named_catalogs_mutex_); const char* const* names = nullptr; size_t count = 0; Check(detail::api()->Manager_ListCatalogNames(handle_.get(), &names, &count)); From 198cd70a0c0fa33e6469db1b363841900d24d0c9 Mon Sep 17 00:00:00 2001 From: Emmanuel <91394589+kobby-kobbs@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:16:40 -0700 Subject: [PATCH 11/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sdk_v2/cpp/include/foundry_local/foundry_local_c.h | 4 ++-- 1 file changed, 2 insertions(+), 2 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 701e621e2..adf8799ff 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -929,8 +929,8 @@ struct flConfigurationApi { FL_API_STATUS(AddCatalogUrl, _In_ flConfiguration* config, _In_ const char* url, _In_opt_ const char* filter_override); /// Optional. Add a named catalog. Defaults to the Azure Foundry Local Catalog if none added. - /// Multiple catalogs can be added; each must have a unique name. Priority follows add-order. - /// The name is used to address the catalog for scoped list/download operations. + /// Multiple catalogs can be added; each must have a unique name. The first added catalog is the default. + /// Each catalog is addressed independently by name for scoped list/download operations. /// The name "public" is reserved for the built-in default catalog. /// @param filter_override Optional filter string for this catalog. Pass NULL for no override. FL_API_STATUS(AddCatalog, _In_ flConfiguration* config, _In_ const char* name, _In_ const char* url, From b232839b40d1cd2ea019f6cf13b3e63460909492 Mon Sep 17 00:00:00 2001 From: Emmanuel <91394589+kobby-kobbs@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:16:52 -0700 Subject: [PATCH 12/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sdk_v2/cpp/src/manager.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index ed3402a07..e48a3172a 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -45,7 +45,7 @@ class Manager { /// Destroy the singleton and release all resources. static void Destroy(); - /// Get the default (public) catalog interface for querying models. + /// Get the default catalog interface for querying models. /// 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(); From 9b321057e9e183c54851a04b744d1748bff7de3c Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Mon, 3 Aug 2026 20:22:09 -0700 Subject: [PATCH 13/14] Address review feedback on named multi-catalog support - configuration: reject reserved ("public") and duplicate catalog names in Validate(), with tests covering both cases. - C ABI: move AddCatalog to the end of flConfigurationApi (append-only rule) so clients compiled against the previous vtable keep dispatching correctly. - C++ wrapper: route the no-arg GetCatalog() through the named cache so the default catalog resolves to a single canonical wrapper shared by both paths. - catalog cache: namespace the per-catalog metadata snapshot by catalog identity (URL/filter) while keeping the shared model-blob cache directory, so separately addressable catalogs no longer collide on foundry.modelinfo.json. The default ("public") catalog keeps the canonical file name. --- .../include/foundry_local/foundry_local_c.h | 15 +++++------ .../include/foundry_local/foundry_local_cpp.h | 6 +++-- .../foundry_local/foundry_local_cpp.inline.h | 13 ++++++---- sdk_v2/cpp/src/c_api.cc | 2 +- sdk_v2/cpp/src/catalog/azure_model_catalog.cc | 25 +++++++++++++++++-- sdk_v2/cpp/src/catalog/azure_model_catalog.h | 6 +++++ sdk_v2/cpp/src/catalog/catalog_cache.cc | 5 ++-- sdk_v2/cpp/src/catalog/catalog_cache.h | 16 ++++++++---- sdk_v2/cpp/src/configuration.cc | 10 ++++++++ .../test/internal_api/configuration_test.cc | 17 ++++++++++++- 10 files changed, 90 insertions(+), 25 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 adf8799ff..02dee893c 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -928,13 +928,6 @@ struct flConfigurationApi { /// @param filter_override Optional filter string for this catalog. Pass NULL for no override. FL_API_STATUS(AddCatalogUrl, _In_ flConfiguration* config, _In_ const char* url, _In_opt_ const char* filter_override); - /// Optional. Add a named catalog. Defaults to the Azure Foundry Local Catalog if none added. - /// Multiple catalogs can be added; each must have a unique name. The first added catalog is the default. - /// Each catalog is addressed independently by name for scoped list/download operations. - /// The name "public" is reserved for the built-in default catalog. - /// @param filter_override Optional filter string for this catalog. Pass NULL for no override. - FL_API_STATUS(AddCatalog, _In_ flConfiguration* config, _In_ const char* name, _In_ const char* url, - _In_opt_ const char* filter_override); /// Optional. Azure region for the model registry download endpoint /// (https://{region}.api.azureml.ms/modelregistry/...). Resolves a model's /// asset_id to a downloadable blob storage URL. Defaults to "centralus" when not set. @@ -953,6 +946,14 @@ struct flConfigurationApi { /// These are passed through to the core implementation. The configuration copies the data. FL_API_STATUS(SetAdditionalOptions, _In_ flConfiguration* config, _In_ const flKeyValuePairs* options); + /// Optional. Add a named catalog. Defaults to the Azure Foundry Local Catalog if none added. + /// Multiple catalogs can be added; each must have a unique name. The first added catalog is the default. + /// Each catalog is addressed independently by name for scoped list/download operations. + /// The name "public" is reserved for the built-in default catalog. + /// @param filter_override Optional filter string for this catalog. Pass NULL for no override. + FL_API_STATUS(AddCatalog, _In_ flConfiguration* config, _In_ const char* name, _In_ const char* url, + _In_opt_ const char* filter_override); + // End V1 }; diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h index d158ecaf8..6bd25b941 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -884,11 +884,13 @@ class Manager { private: detail::Base handle_; Configuration config_; - mutable std::unique_ptr catalog_; - mutable std::unique_ptr catalog_once_{std::make_unique()}; // Cache of named catalog wrappers, guarded by named_catalogs_mutex_ for concurrent access. + // The no-argument GetCatalog() resolves the default catalog through this same cache, so both + // access paths return one canonical wrapper per catalog. mutable std::map> named_catalogs_; mutable std::unique_ptr named_catalogs_mutex_{std::make_unique()}; + mutable std::string default_catalog_name_; + mutable std::unique_ptr default_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 b35d0d2c4..cc3918074 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,12 +203,15 @@ inline Manager::Manager(Configuration&& config) config_(std::move(config)) {} 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)); + // Resolve the default catalog's name once (the first registered catalog), then + // route through the named cache so the no-argument and by-name paths share one wrapper. + std::call_once(*default_catalog_once_, [this]() { + std::vector names = ListCatalogNames(); + if (!names.empty()) { + default_catalog_name_ = names.front(); + } }); - return *catalog_; + return GetCatalog(default_catalog_name_); } inline ICatalog& Manager::GetCatalog(const std::string& name) const { diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index 05abd7529..9e6c75a0e 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -328,11 +328,11 @@ static const flConfigurationApi g_configuration_api = { SetLogsDirImpl, SetModelCacheDirImpl, AddCatalogUrlImpl, - AddCatalogImpl, SetCatalogRegionImpl, AddWebServiceEndpointImpl, SetExternalServiceUrlImpl, SetAdditionalOptionsImpl, + AddCatalogImpl, }; // ======================================================================== diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc index 39afcae37..25d64d744 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc @@ -12,6 +12,7 @@ #include #include +#include #include namespace fl { @@ -44,6 +45,26 @@ AzureModelCatalog::AzureModelCatalog(std::vector{}(identity); + return fmt::format("foundry.modelinfo.{:016x}.json", hash); +} + 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 @@ -55,7 +76,7 @@ std::vector AzureModelCatalog::FetchModels() const { // we could update 'cache_only_' mode to enable refreshing the cache info if it is old. The cache file has a // savedAtUnix timestamp property that can be used. if (cache_only_) { - CatalogCache cache(cache_dir_, logger_); + CatalogCache cache(cache_dir_, logger_, CacheFileName()); cache.Load(); auto cached = cache.GetCachedModels(); @@ -128,7 +149,7 @@ std::vector AzureModelCatalog::FetchModels() const { // its own errors and freshness checks. If nothing was fetched, leave the existing // cache untouched. if (!fetched_infos.empty()) { - CatalogCache cache(cache_dir_, logger_); + CatalogCache cache(cache_dir_, logger_, CacheFileName()); cache.Save(fetched_infos); } diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.h b/sdk_v2/cpp/src/catalog/azure_model_catalog.h index 5769a3ef7..31f7ed805 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.h @@ -41,6 +41,12 @@ class AzureModelCatalog : public BaseModelCatalog { static constexpr const char* kDefaultCatalogUrl = "https://ai.azure.com/api/centralus/ux/v1.0"; static constexpr const char* kDefaultCatalogFilter = "''"; + // Metadata snapshot file name for this catalog. Separately addressable catalogs share the + // model-blob cache directory but must not share their metadata snapshot, so each non-default + // catalog gets a file derived from its URL/filter identity. The default ("public") catalog + // keeps the canonical "foundry.modelinfo.json" for compatibility with the hosting service. + std::string CacheFileName() const; + std::vector>> catalog_urls_; std::string cache_dir_; ModelFactory model_factory_; diff --git a/sdk_v2/cpp/src/catalog/catalog_cache.cc b/sdk_v2/cpp/src/catalog/catalog_cache.cc index b8b03c176..3d443e268 100644 --- a/sdk_v2/cpp/src/catalog/catalog_cache.cc +++ b/sdk_v2/cpp/src/catalog/catalog_cache.cc @@ -66,15 +66,16 @@ std::optional> ParseCatalogSnapshot( } } -CatalogCache::CatalogCache(std::string cache_directory, ILogger& logger) +CatalogCache::CatalogCache(std::string cache_directory, ILogger& logger, std::string cache_file_name) : cache_directory_(std::move(cache_directory)), + cache_file_name_(std::move(cache_file_name)), logger_(logger) { static_assert(kSnapshotVersion == CatalogCache::kCacheVersion, "snapshot parser version must match cache writer version"); } std::string CatalogCache::CacheFilePath() const { - return (fs::path(cache_directory_) / kCacheFileName).string(); + return (fs::path(cache_directory_) / cache_file_name_).string(); } void CatalogCache::Load() { diff --git a/sdk_v2/cpp/src/catalog/catalog_cache.h b/sdk_v2/cpp/src/catalog/catalog_cache.h index a35ccfacb..f4c6c4551 100644 --- a/sdk_v2/cpp/src/catalog/catalog_cache.h +++ b/sdk_v2/cpp/src/catalog/catalog_cache.h @@ -26,12 +26,18 @@ std::optional> ParseCatalogSnapshot( ILogger& logger); /// Best-effort disk cache for catalog model information. -/// Caches the model list to `foundry.modelinfo.json` in the specified directory. -/// All operations are no-throw — cache failures are logged and silently ignored. +/// Caches the model list to `foundry.modelinfo.json` (or a per-catalog variant) in the +/// specified directory. All operations are no-throw — cache failures are logged and silently ignored. class CatalogCache { public: - /// Construct with the directory where the cache file will be stored. - explicit CatalogCache(std::string cache_directory, ILogger& logger); + /// Default metadata snapshot file name, used for the built-in default ("public") catalog. + static constexpr const char* kDefaultCacheFileName = "foundry.modelinfo.json"; + + /// Construct with the directory where the cache file will be stored. Separately addressable + /// catalogs share one model-blob cache directory but must not share their metadata snapshot, + /// so callers pass a per-catalog `cache_file_name` to keep the snapshots isolated. + explicit CatalogCache(std::string cache_directory, ILogger& logger, + std::string cache_file_name = kDefaultCacheFileName); /// Load cached models from disk into memory. Silently handles missing/corrupt files. void Load(); @@ -46,11 +52,11 @@ class CatalogCache { std::string CacheFilePath() const; std::string cache_directory_; + std::string cache_file_name_; std::optional> cached_models_; ILogger& logger_; static constexpr auto kFreshnessThreshold = std::chrono::hours(4); - static constexpr const char* kCacheFileName = "foundry.modelinfo.json"; static constexpr int kCacheVersion = 1; }; diff --git a/sdk_v2/cpp/src/configuration.cc b/sdk_v2/cpp/src/configuration.cc index d3872ba9c..8e779d52b 100644 --- a/sdk_v2/cpp/src/configuration.cc +++ b/sdk_v2/cpp/src/configuration.cc @@ -5,6 +5,7 @@ #include "utils.h" #include +#include namespace fl { @@ -42,6 +43,7 @@ void Configuration::Validate() { } // Validate catalog URLs are non-empty strings if present + std::set seen_names; for (const auto& source : catalog_urls) { if (source.url.empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "Configuration: catalog URL must not be empty"); @@ -49,6 +51,14 @@ void Configuration::Validate() { if (source.name.empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "Configuration: catalog name must not be empty"); } + if (source.name == kDefaultCatalogName) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + "Configuration: catalog name '" + source.name + "' is reserved for the built-in default catalog"); + } + if (!seen_names.insert(source.name).second) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, + "Configuration: duplicate catalog name '" + source.name + "'"); + } } // Validate web service endpoints are non-empty strings if present diff --git a/sdk_v2/cpp/test/internal_api/configuration_test.cc b/sdk_v2/cpp/test/internal_api/configuration_test.cc index 47cc695bd..b3503b0db 100644 --- a/sdk_v2/cpp/test/internal_api/configuration_test.cc +++ b/sdk_v2/cpp/test/internal_api/configuration_test.cc @@ -43,6 +43,21 @@ TEST(ConfigurationTest, ValidateRejectsEmptyCatalogName) { EXPECT_THROW(config.Validate(), fl::Exception); } +TEST(ConfigurationTest, ValidateRejectsReservedCatalogName) { + Configuration config; + config.app_name = "test_app"; + config.catalog_urls.push_back(CatalogSource{"public", "https://example.com/catalog", std::string("")}); + EXPECT_THROW(config.Validate(), fl::Exception); +} + +TEST(ConfigurationTest, ValidateRejectsDuplicateCatalogNames) { + Configuration config; + config.app_name = "test_app"; + config.catalog_urls.push_back(CatalogSource{"custom", "https://example.com/first", std::string("")}); + config.catalog_urls.push_back(CatalogSource{"custom", "https://example.com/second", std::string("")}); + EXPECT_THROW(config.Validate(), fl::Exception); +} + TEST(ConfigurationTest, ValidateRejectsEmptyEndpoint) { Configuration config; config.app_name = "test_app"; @@ -53,7 +68,7 @@ TEST(ConfigurationTest, ValidateRejectsEmptyEndpoint) { TEST(ConfigurationTest, ValidateAcceptsCatalogUrlsAndEndpoints) { Configuration config; config.app_name = "test_app"; - config.catalog_urls.push_back(CatalogSource{"public", "https://example.com/catalog", std::string("")}); + config.catalog_urls.push_back(CatalogSource{"custom", "https://example.com/catalog", std::string("")}); config.web_service_endpoints.emplace_back("http://127.0.0.1:0"); EXPECT_NO_THROW(config.Validate()); } From ba8bfe12f89caf7ef61635628fa82c9010c7cbaf Mon Sep 17 00:00:00 2001 From: Emmanuel Assumang Date: Tue, 4 Aug 2026 11:22:28 -0700 Subject: [PATCH 14/14] Fix CI: gcc -Werror in c_api.cc and Manager singleton test conflict - c_api.cc: fully initialize the flManager aggregate in Manager_CreateImpl. Three new members (catalog_by_name, catalog_names_storage, catalog_names_cache) were left out of the brace-init, which trips gcc's -Wmissing-field-initializers (-Wextra), promoted to an error by -Werror on the Linux build. MSVC (/EHsc only) ignored it, so local Windows builds passed. - Move the named-catalog tests (default-catalog, resolve-by-name, unknown-name-throws) out of cpp_api_test.cc (sdk_integration_tests binary, whose SharedTestEnv holds the process-wide Manager singleton) and into catalog_live_test.cc (cache_only_tests binary, no SharedTestEnv). Each test constructs its own Manager, which threw "Manager already created" in the old binary. Tests use a temp cache dir and are metadata-only (no network). --- sdk_v2/cpp/src/c_api.cc | 4 +- sdk_v2/cpp/test/sdk_api/catalog_live_test.cc | 54 ++++++++++++++++++++ sdk_v2/cpp/test/sdk_api/cpp_api_test.cc | 35 ------------- 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index 9e6c75a0e..5931c2ddb 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -351,7 +351,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, {}}); + // Initialize every field explicitly: gcc's -Wextra flags -Wmissing-field-initializers + // (promoted to an error by -Werror) if any aggregate member is left out. + auto wrapper = std::make_unique(flManager{mgr, nullptr, {}, {}, {}, {}}); wrapper->catalog = std::make_unique(flCatalog{mgr.GetCatalog()}); *out_manager = wrapper.release(); return nullptr; diff --git a/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc b/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc index 559e14cd1..add91e01e 100644 --- a/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc +++ b/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc @@ -149,3 +149,57 @@ TEST(CatalogLiveTest, DISABLED_DownloadRealModel) { << e.what(); } } + +// --------------------------------------------------------------------------- +// Named multi-catalog tests. +// +// These construct their own Manager with custom catalog configurations, so they +// live in this binary (cache_only_tests) rather than sdk_integration_tests: the +// latter's SharedTestEnv holds the process-wide Manager singleton for its whole +// run, which would make "Manager already created" throw here. Named catalog +// resolution and enumeration are metadata-only (no network fetch), so a temp +// cache dir keeps them hermetic and offline. +// --------------------------------------------------------------------------- + +TEST(NamedCatalogTest, ListCatalogNamesDefaultsToPublic) { + TempDirGuard cache("named_default"); + foundry_local::Configuration config("test_default_catalog"); + config.SetModelCacheDir(cache.path.string()); + foundry_local::Manager manager(std::move(config)); + + auto names = manager.ListCatalogNames(); + ASSERT_EQ(names.size(), 1u); + EXPECT_EQ(names[0], "public"); +} + +TEST(NamedCatalogTest, NamedCatalogsResolveByName) { + TempDirGuard cache("named_resolve"); + foundry_local::Configuration config("test_named_catalogs"); + config.SetModelCacheDir(cache.path.string()) + .AddCatalog("first", "https://example.com/first") + .AddCatalog("second", "https://example.com/second"); + foundry_local::Manager manager(std::move(config)); + + auto names = manager.ListCatalogNames(); + ASSERT_EQ(names.size(), 2u); + EXPECT_EQ(names[0], "first"); + EXPECT_EQ(names[1], "second"); + + // Named lookup resolves each catalog; the no-arg GetCatalog() returns the first (default). + auto& first = manager.GetCatalog("first"); + auto& second = manager.GetCatalog("second"); + EXPECT_NE(&first, &second); + EXPECT_EQ(&manager.GetCatalog(), &first); + + // Repeated lookups return the same cached wrapper. + EXPECT_EQ(&manager.GetCatalog("first"), &first); +} + +TEST(NamedCatalogTest, GetCatalogUnknownNameThrows) { + TempDirGuard cache("named_unknown"); + foundry_local::Configuration config("test_unknown_catalog"); + config.SetModelCacheDir(cache.path.string()); + foundry_local::Manager manager(std::move(config)); + + EXPECT_THROW(manager.GetCatalog("does-not-exist"), foundry_local::Error); +} diff --git a/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc b/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc index a7776f854..7ca85b005 100644 --- a/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc +++ b/sdk_v2/cpp/test/sdk_api/cpp_api_test.cc @@ -343,41 +343,6 @@ TEST(CppApiTest, ConfigurationAddCatalogChaining) { // Should not throw — just verify chaining compiles and runs. } -TEST(CppApiTest, ManagerListCatalogNamesDefaultsToPublic) { - foundry_local::Manager manager(foundry_local::Configuration("test_default_catalog")); - - auto names = manager.ListCatalogNames(); - ASSERT_EQ(names.size(), 1u); - EXPECT_EQ(names[0], "public"); -} - -TEST(CppApiTest, ManagerNamedCatalogsResolveByName) { - foundry_local::Configuration config("test_named_catalogs"); - config.AddCatalog("first", "https://example.com/first") - .AddCatalog("second", "https://example.com/second"); - foundry_local::Manager manager(std::move(config)); - - auto names = manager.ListCatalogNames(); - ASSERT_EQ(names.size(), 2u); - EXPECT_EQ(names[0], "first"); - EXPECT_EQ(names[1], "second"); - - // Named lookup resolves each catalog; the no-arg GetCatalog() returns the first (default). - auto& first = manager.GetCatalog("first"); - auto& second = manager.GetCatalog("second"); - EXPECT_NE(&first, &second); - EXPECT_EQ(&manager.GetCatalog(), &first); - - // Repeated lookups return the same cached wrapper. - EXPECT_EQ(&manager.GetCatalog("first"), &first); -} - -TEST(CppApiTest, ManagerGetCatalogUnknownNameThrows) { - foundry_local::Manager manager(foundry_local::Configuration("test_unknown_catalog")); - - EXPECT_THROW(manager.GetCatalog("does-not-exist"), foundry_local::Error); -} - TEST(CppApiTest, ErrorFromCode) { foundry_local::Error err("test error", FOUNDRY_LOCAL_ERROR_INTERNAL); EXPECT_EQ(err.Code(), FOUNDRY_LOCAL_ERROR_INTERNAL);