diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index 47cbf270f..ae45ab73c 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -267,6 +267,7 @@ set(FOUNDRY_LOCAL_SOURCES src/utils.cc src/util/file_lock.cc src/http/http_download.cc + src/util/model_layout.cc src/util/path_safety.cc src/util/region_fallback.cc src/util/sha256.cc diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_models.cc b/sdk_v2/cpp/src/catalog/azure_catalog_models.cc index 960ff0cb9..c8ebe64d3 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_models.cc +++ b/sdk_v2/cpp/src/catalog/azure_catalog_models.cc @@ -57,20 +57,44 @@ int64_t ParseIso8601ToUnix(const std::string& iso_str) { return t == static_cast(-1) ? 0 : static_cast(t); } -DeviceType ParseDeviceType(const std::string& device) { - const auto lower = ToLower(device); - if (lower == "cpu") { - return DeviceType::kCPU; +std::optional ToModelVariantMetadata(const VariantMetadata& variant_metadata) { + if (!variant_metadata.model_format.has_value() && !variant_metadata.model_package.has_value()) { + return std::nullopt; } - if (lower == "gpu") { - return DeviceType::kGPU; - } + ModelVariantMetadata metadata; + metadata.model_format = variant_metadata.model_format; + + if (variant_metadata.model_package.has_value()) { + ModelPackageMetadata package_metadata; + package_metadata.schema_version = variant_metadata.model_package->schema_version; + package_metadata.variants.reserve(variant_metadata.model_package->variants.size()); + + for (const auto& catalog_variant : variant_metadata.model_package->variants) { + ModelPackageVariant package_variant; + if (catalog_variant.name.has_value()) { + package_variant.name = *catalog_variant.name; + } + + if (catalog_variant.execution_provider.has_value()) { + package_variant.execution_provider = *catalog_variant.execution_provider; + } + + if (catalog_variant.device.has_value()) { + package_variant.device_type = DeviceTypeFromString(*catalog_variant.device); + } + + if (catalog_variant.compatibility_string.has_value()) { + package_variant.compatibility_string = *catalog_variant.compatibility_string; + } + + package_metadata.variants.push_back(std::move(package_variant)); + } - if (lower == "npu") { - return DeviceType::kNPU; + metadata.model_package = std::move(package_metadata); } - return DeviceType::kNotSet; + + return metadata; } } // anonymous namespace @@ -120,11 +144,31 @@ void to_json(nlohmann::json& j, const AzureCatalogRequest& r) { // Response deserialization (from_json) // ======================================================================== +void from_json(const nlohmann::json& j, CatalogModelPackageVariant& v) { + opt_str(j, "name", v.name); + opt_str(j, "executionProvider", v.execution_provider); + opt_str(j, "device", v.device); + opt_str(j, "compatibilityString", v.compatibility_string); +} + +void from_json(const nlohmann::json& j, CatalogModelPackageMetadata& p) { + opt_int(j, "schemaVersion", p.schema_version); + + if (j.contains("variants") && j["variants"].is_array()) { + p.variants = j["variants"].get>(); + } +} + void from_json(const nlohmann::json& j, VariantMetadata& v) { opt_str(j, "modelType", v.model_type); opt_str(j, "device", v.device); opt_str(j, "executionProvider", v.execution_provider); opt_int64(j, "fileSizeBytes", v.file_size_bytes); + opt_str(j, "modelFormat", v.model_format); + + if (j.contains("modelPackage") && j["modelPackage"].is_object()) { + v.model_package = j["modelPackage"].get(); + } } void from_json(const nlohmann::json& j, VariantParent& v) { @@ -286,13 +330,15 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { if (props.variant_info && props.variant_info->variant_metadata) { const auto& vm = *props.variant_info->variant_metadata; if (vm.device) { - info.device_type = ParseDeviceType(*vm.device); + info.device_type = DeviceTypeFromString(*vm.device); } if (vm.execution_provider) { info.execution_provider = *vm.execution_provider; } + info.variant_metadata = ToModelVariantMetadata(vm); + // ModelType — defaults to "ONNX" (matches C# ToAzureFoundryLocalModel) info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR] = vm.model_type.value_or("ONNX"); diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_models.h b/sdk_v2/cpp/src/catalog/azure_catalog_models.h index 76cbee132..cdf86e2e0 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_models.h +++ b/sdk_v2/cpp/src/catalog/azure_catalog_models.h @@ -47,12 +47,26 @@ struct AzureCatalogRequest { // --- Response types --- +struct CatalogModelPackageVariant { + std::optional name; + std::optional execution_provider; + std::optional device; + std::optional compatibility_string; +}; + +struct CatalogModelPackageMetadata { + std::optional schema_version; + std::vector variants; +}; + /// Variant metadata nested inside Properties → VariantInfo. struct VariantMetadata { std::optional model_type; std::optional device; std::optional execution_provider; std::optional file_size_bytes; + std::optional model_format; + std::optional model_package; }; struct VariantParent { @@ -148,6 +162,8 @@ void to_json(nlohmann::json& j, const AzureCatalogRequest& r); // --- Response deserialization (from_json) --- +void from_json(const nlohmann::json& j, CatalogModelPackageVariant& v); +void from_json(const nlohmann::json& j, CatalogModelPackageMetadata& p); void from_json(const nlohmann::json& j, VariantMetadata& v); void from_json(const nlohmann::json& j, VariantParent& v); void from_json(const nlohmann::json& j, VariantInfo& v); diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc index 63776436c..73063852d 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc @@ -48,6 +48,137 @@ std::vector DeduplicateByModelId(std::vector model_infos) return deduplicated; } +bool IsCompatible(CompiledModelCompatibility compatibility) { + return compatibility == CompiledModelCompatibility::kSupportedOptimal || + compatibility == CompiledModelCompatibility::kSupportedPreferRecompilation; +} + +bool IsRelevantPackageVariant(const ModelInfo& info, const ModelPackageVariant& package_variant) { + if (!info.execution_provider.empty() && !package_variant.execution_provider.empty() && + info.execution_provider != package_variant.execution_provider) { + return false; + } + + if (info.device_type != DeviceType::kNotSet && + package_variant.device_type != DeviceType::kNotSet && + info.device_type != package_variant.device_type) { + return false; + } + + return true; +} + +std::optional GetDeviceConstraint(const ModelInfo& info, + const ModelPackageVariant& package_variant) { + if (package_variant.device_type != DeviceType::kNotSet) { + return package_variant.device_type; + } + + if (info.device_type != DeviceType::kNotSet) { + return info.device_type; + } + + return std::nullopt; +} + +std::string_view GetRequestedExecutionProvider(const ModelInfo& info, + const ModelPackageVariant& package_variant) { + if (!package_variant.execution_provider.empty()) { + return package_variant.execution_provider; + } + + return info.execution_provider; +} + +bool ShouldExposeModelInfo(const ModelInfo& info, + const IEpDetector& ep_detector, + ILogger& logger) { + if (!info.variant_metadata.has_value() || + !info.variant_metadata->model_package.has_value() || + info.variant_metadata->model_package->variants.empty()) { + return true; + } + + bool saw_relevant_variant = false; + bool saw_unknown_compatibility = false; + + for (const auto& package_variant : info.variant_metadata->model_package->variants) { + if (!IsRelevantPackageVariant(info, package_variant)) { + continue; + } + + saw_relevant_variant = true; + + const auto execution_provider = GetRequestedExecutionProvider(info, package_variant); + if (execution_provider.empty() || package_variant.compatibility_string.empty()) { + logger.Log(LogLevel::Debug, + fmt::format("Keeping catalog model '{}' because package variant '{}' has incomplete " + "compatibility metadata.", + info.model_id, + package_variant.name)); + saw_unknown_compatibility = true; + continue; + } + + const auto compatibility = ep_detector.GetModelCompatibilityForEpDevices( + execution_provider, + GetDeviceConstraint(info, package_variant), + package_variant.compatibility_string); + + if (IsCompatible(compatibility)) { + return true; + } + + if (compatibility == CompiledModelCompatibility::kUnknown) { + logger.Log(LogLevel::Debug, + fmt::format("Keeping catalog model '{}' because compatibility is unknown for package " + "variant '{}'.", + info.model_id, + package_variant.name)); + saw_unknown_compatibility = true; + } + } + + if (!saw_relevant_variant) { + logger.Log(LogLevel::Debug, + fmt::format("Keeping catalog model '{}' because no package variant definitively matches " + "its catalog EP/device.", + info.model_id)); + return true; + } + + if (saw_unknown_compatibility) { + return true; + } + + logger.Log(LogLevel::Debug, + fmt::format("Filtering catalog model '{}' because all relevant ORT model package variants " + "are unsupported.", + info.model_id)); + return false; +} + +/// Drops catalog entries whose ORT model package variants are all known to be unsupported +/// on the current hardware. Entries without package metadata are always kept. Ids of the +/// dropped entries are collected in `hidden_model_ids` so they aren't resurrected elsewhere. +std::vector FilterVisibleInfos(std::vector model_infos, + const IEpDetector& ep_detector, + ILogger& logger, + std::unordered_set* hidden_model_ids = nullptr) { + std::vector visible_infos; + visible_infos.reserve(model_infos.size()); + + for (auto& info : model_infos) { + if (ShouldExposeModelInfo(info, ep_detector, logger)) { + visible_infos.push_back(std::move(info)); + } else if (hidden_model_ids != nullptr) { + hidden_model_ids->insert(info.model_id); + } + } + + return visible_infos; +} + } // namespace AzureModelCatalog::AzureModelCatalog(std::vector>> catalog_urls, @@ -123,12 +254,15 @@ AzureModelCatalog::CatalogResult AzureModelCatalog::GetLiveCatalogOrLocalSnapsho } std::vector AzureModelCatalog::AddLocalModels(std::vector& model_infos, - const LocalModels& local_models) const { + const LocalModels& local_models, + const std::unordered_set& hidden_model_ids) const { std::vector models; models.reserve(model_infos.size() + local_models.size()); - std::unordered_set model_ids; - model_ids.reserve(model_infos.size() + local_models.size()); + // Seeding with the hidden ids keeps catalog models that were filtered out as unsupported from + // being re-added as synthesized local BYOM entries. + std::unordered_set model_ids(hidden_model_ids); + model_ids.reserve(model_ids.size() + model_infos.size() + local_models.size()); for (const auto& info : model_infos) { model_ids.insert(info.model_id); @@ -162,7 +296,10 @@ std::vector AzureModelCatalog::FetchModels() const { logger_.Log(LogLevel::Information, fmt::format("Found {} locally cached models.", cached_model_ids.size())); auto catalog_result = GetLiveCatalogOrLocalSnapshot(cached_model_ids); - auto models = AddLocalModels(catalog_result.model_infos, local_models); + std::unordered_set hidden_model_ids; + catalog_result.model_infos = + FilterVisibleInfos(std::move(catalog_result.model_infos), ep_detector_, logger_, &hidden_model_ids); + auto models = AddLocalModels(catalog_result.model_infos, local_models, hidden_model_ids); logger_.Log(LogLevel::Information, fmt::format("Populated model info for {} models.", models.size())); @@ -189,6 +326,7 @@ std::vector AzureModelCatalog::FetchModelVersions( try { auto client = CreateCatalogClient(url, filter.value_or("")); auto model_infos = client->FetchAllVersionsByAlias(model_alias, model_name); + model_infos = FilterVisibleInfos(std::move(model_infos), ep_detector_, logger_); out.reserve(out.size() + model_infos.size()); for (auto& info : model_infos) { @@ -233,6 +371,7 @@ std::vector AzureModelCatalog::FetchModelsByIds(const std::vectorFetchModelsByIds(remaining); + model_infos = FilterVisibleInfos(std::move(model_infos), ep_detector_, logger_); for (auto& info : model_infos) { std::string local_path; diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.h b/sdk_v2/cpp/src/catalog/azure_model_catalog.h index b7bb3bc6e..478b5490f 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -60,7 +61,9 @@ class AzureModelCatalog : public BaseModelCatalog { static constexpr const char* kDefaultCatalogFilter = "''"; CatalogResult GetLiveCatalogOrLocalSnapshot(const std::vector& cached_model_ids) const; - std::vector AddLocalModels(std::vector& model_infos, const LocalModels& local_models) const; + std::vector AddLocalModels(std::vector& model_infos, + const LocalModels& local_models, + const std::unordered_set& hidden_model_ids) const; std::vector>> catalog_urls_; std::string cache_dir_; diff --git a/sdk_v2/cpp/src/catalog/local_model_scanner.cc b/sdk_v2/cpp/src/catalog/local_model_scanner.cc index 3d41661d5..47d1a33ed 100644 --- a/sdk_v2/cpp/src/catalog/local_model_scanner.cc +++ b/sdk_v2/cpp/src/catalog/local_model_scanner.cc @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #include "catalog/local_model_scanner.h" +#include "util/model_layout.h" #include #include @@ -15,7 +16,6 @@ namespace fs = std::filesystem; namespace { -const char* kGenAIConfigFileName = "genai_config.json"; const char* kDownloadSignalFileName = "download.tmp"; const char* kInferenceModelFileName = "inference_model.json"; @@ -44,22 +44,34 @@ std::string ReadModelNameFromInferenceModel(const fs::path& dir) { return {}; } -/// Check if a directory is a valid cached model directory. -/// Must have genai_config.json, no download.tmp, and an inference_model.json with a Name. -bool IsValidModelDirectory(const fs::path& dir) { - if (!fs::exists(dir / kGenAIConfigFileName)) { +bool HasCompletionMarker(const fs::path& dir) { + std::error_code ec; + const auto is_regular_file = fs::is_regular_file(dir / kInferenceModelFileName, ec); + return !ec && is_regular_file; +} + +bool IsCompleteModelDirectory(const fs::path& dir) { + std::error_code ec; + const auto download_in_progress = fs::exists(dir / kDownloadSignalFileName, ec); + if (ec || download_in_progress) { return false; } - if (fs::exists(dir / kDownloadSignalFileName)) { - return false; // Incomplete download. + return HasCompletionMarker(dir); +} + +void AddModelDirectory(const fs::path& dir, std::map& results) { + auto model_name = ReadModelNameFromInferenceModel(dir); + if (model_name.empty()) { + return; } - if (!fs::exists(dir / kInferenceModelFileName)) { - return false; + // If the model name doesn't contain a ':' version separator, append ":0". + if (model_name.find(':') == std::string::npos) { + model_name += ":0"; } - return true; + results[model_name] = dir.string(); } /// Recursively scan a directory for valid model directories. @@ -71,19 +83,18 @@ void ScanDirectory(const fs::path& dir, return; } - // Check if this directory itself is a valid model directory. - if (IsValidModelDirectory(dir)) { - auto model_name = ReadModelNameFromInferenceModel(dir); - if (!model_name.empty()) { - // If the model name doesn't contain a ':' version separator, append ":0". - if (model_name.find(':') == std::string::npos) { - model_name += ":0"; - } - - results[model_name] = dir.string(); + const auto layout = ClassifyModelLayout(dir); + if (layout == ModelLayout::ModelPackage) { + if (IsCompleteModelDirectory(dir)) { + AddModelDirectory(dir, results); } - // Don't recurse into a valid model directory — it's a leaf. + // Package children are implementation details, not independently loadable models. + return; + } + + if (layout == ModelLayout::FlatModel && IsCompleteModelDirectory(dir)) { + AddModelDirectory(dir, results); return; } diff --git a/sdk_v2/cpp/src/catalog/local_model_scanner.h b/sdk_v2/cpp/src/catalog/local_model_scanner.h index 0add84aa6..faf37e337 100644 --- a/sdk_v2/cpp/src/catalog/local_model_scanner.h +++ b/sdk_v2/cpp/src/catalog/local_model_scanner.h @@ -11,8 +11,8 @@ namespace fl { /// Scan a model cache directory for locally cached (downloaded) models. /// Returns a map of model_id -> local_path for each valid model found. -/// A valid model directory has genai_config.json, no download.tmp, -/// and an inference_model.json with a model name. +/// Flat models and model packages must have no download.tmp and must have a root +/// inference_model.json containing a model name. std::map ScanLocalModels(const std::string& cache_directory, ILogger& logger); diff --git a/sdk_v2/cpp/src/download/download_manager.cc b/sdk_v2/cpp/src/download/download_manager.cc index f7219e7e8..835773b64 100644 --- a/sdk_v2/cpp/src/download/download_manager.cc +++ b/sdk_v2/cpp/src/download/download_manager.cc @@ -6,6 +6,7 @@ #include "exception.h" #include "log_level.h" #include "logger.h" +#include "util/model_layout.h" #include "util/path_safety.h" #include "util/region_fallback.h" #include "utils.h" @@ -29,14 +30,28 @@ const char* kGenAIConfigFileName = "genai_config.json"; const char* kInferenceModelFileName = "inference_model.json"; const char* kDefaultRegistryRegion = "centralus"; -/// Check whether inference_model.json exists at the root or in any immediate -/// subdirectory. This is the definitive proof that a download completed -/// successfully — DownloadModel writes it in Step 3. +bool IsRegularFile(const std::filesystem::path& path) { + std::error_code ec; + const auto is_regular_file = std::filesystem::is_regular_file(path, ec); + return !ec && is_regular_file; +} + +/// Check for the completion marker at the location required by the model layout. bool HasInferenceModelJson(const std::string& model_path) { - if (std::filesystem::exists(std::filesystem::path(model_path) / kInferenceModelFileName)) { + const auto root = std::filesystem::path(model_path); + const auto layout = ClassifyModelLayout(root); + if (layout == ModelLayout::Invalid) { + return false; + } + + if (IsRegularFile(root / kInferenceModelFileName)) { return true; } + if (layout == ModelLayout::ModelPackage) { + return false; + } + std::error_code ec; std::filesystem::directory_iterator it(model_path, ec); if (ec) { @@ -45,7 +60,7 @@ bool HasInferenceModelJson(const std::string& model_path) { for (const auto& entry : it) { if (entry.is_directory(ec)) { - if (std::filesystem::exists(entry.path() / kInferenceModelFileName)) { + if (IsRegularFile(entry.path() / kInferenceModelFileName)) { return true; } } @@ -58,6 +73,10 @@ bool HasInferenceModelJson(const std::string& model_path) { /// For single-variant models this is model_path itself. /// For multi-variant models it's the first subdirectory containing genai_config.json. std::string ResolveEffectiveModelPath(const std::string& model_path) { + if (ClassifyModelLayout(model_path) == ModelLayout::ModelPackage) { + return model_path; + } + auto root_config = std::filesystem::path(model_path) / kGenAIConfigFileName; if (std::filesystem::exists(root_config)) { return model_path; diff --git a/sdk_v2/cpp/src/download/download_manager.h b/sdk_v2/cpp/src/download/download_manager.h index 7099dcb8a..cf7cfcec5 100644 --- a/sdk_v2/cpp/src/download/download_manager.h +++ b/sdk_v2/cpp/src/download/download_manager.h @@ -21,7 +21,7 @@ class ILogger; /// 2. Resolve SAS URI from model registry /// 3. Download blobs from Azure Storage /// 4. Write inference_model.json -/// 5. Fix variant download (move inference_model.json into subdirs) +/// 5. Fix legacy flat variant downloads (move inference_model.json into subdirs) class DownloadManager { public: /// Construct with the model cache directory path. diff --git a/sdk_v2/cpp/src/download/inference_model_writer.cc b/sdk_v2/cpp/src/download/inference_model_writer.cc index 69bf4c88f..5f7a1c67a 100644 --- a/sdk_v2/cpp/src/download/inference_model_writer.cc +++ b/sdk_v2/cpp/src/download/inference_model_writer.cc @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "download/inference_model_writer.h" #include "exception.h" +#include "util/model_layout.h" #include @@ -48,6 +49,10 @@ void WriteInferenceModelJson(const std::string& directory, } void FixVariantInferenceModelJson(const std::string& model_directory) { + if (ClassifyModelLayout(model_directory) == ModelLayout::ModelPackage) { + return; + } + auto inference_model_path = std::filesystem::path(model_directory) / kInferenceModelFileName; if (!std::filesystem::exists(inference_model_path)) { return; // Nothing to fix diff --git a/sdk_v2/cpp/src/download/inference_model_writer.h b/sdk_v2/cpp/src/download/inference_model_writer.h index f87334d0e..3d1722deb 100644 --- a/sdk_v2/cpp/src/download/inference_model_writer.h +++ b/sdk_v2/cpp/src/download/inference_model_writer.h @@ -17,7 +17,8 @@ void WriteInferenceModelJson(const std::string& directory, /// Fix the location of inference_model.json for AzureFoundryLocal variants. /// The model blobs download into a sub-directory for the variant, but we don't know the /// name ahead of time. This copies inference_model.json into any sub-directory that -/// doesn't already have it, then deletes the root copy. +/// doesn't already have it, then deletes the root copy. Model packages keep the marker +/// at the package root. /// Matches C# FixAzureFoundryLocalVariantDownload. void FixVariantInferenceModelJson(const std::string& model_directory); diff --git a/sdk_v2/cpp/src/ep_detection/ep_detector.cc b/sdk_v2/cpp/src/ep_detection/ep_detector.cc index 941e3a1b3..4fd6880b1 100644 --- a/sdk_v2/cpp/src/ep_detection/ep_detector.cc +++ b/sdk_v2/cpp/src/ep_detection/ep_detector.cc @@ -4,14 +4,93 @@ #include "ep_detection/ep_bootstrapper.h" #include "logger.h" +#include "model_info.h" #include #include #include +#include +#include namespace fl { +namespace { + +struct EpDeviceSnapshot { + std::vector devices; +}; + +std::optional TryGetEpDeviceSnapshot(const OrtApi& ort_api, + OrtEnv& ort_env, + ILogger& logger) { + const OrtEpDevice* const* ep_devices = nullptr; + size_t num_devices = 0; + OrtStatus* status = ort_api.GetEpDevices(&ort_env, &ep_devices, &num_devices); + + if (status != nullptr) { + const char* message = ort_api.GetErrorMessage(status); + logger.Log(LogLevel::Warning, + std::string("GetEpDevices failed: ") + (message ? message : "unknown")); + ort_api.ReleaseStatus(status); + return std::nullopt; + } + + EpDeviceSnapshot snapshot; + snapshot.devices.reserve(num_devices); + for (size_t i = 0; i < num_devices; ++i) { + snapshot.devices.push_back(ep_devices[i]); + } + + logger.Log(LogLevel::Debug, + std::string("GetEpDevices: ORT reports ") + std::to_string(snapshot.devices.size()) + + " EP device(s)"); + + return snapshot; +} + +const char* DeviceKey(OrtHardwareDeviceType device_type) { + switch (device_type) { + case OrtHardwareDeviceType_CPU: + return "CPU"; + case OrtHardwareDeviceType_GPU: + return "GPU"; + case OrtHardwareDeviceType_NPU: + return "NPU"; + default: + return "CPU"; + } +} + +DeviceType ToDeviceType(OrtHardwareDeviceType device_type) { + switch (device_type) { + case OrtHardwareDeviceType_CPU: + return DeviceType::kCPU; + case OrtHardwareDeviceType_GPU: + return DeviceType::kGPU; + case OrtHardwareDeviceType_NPU: + return DeviceType::kNPU; + default: + return DeviceType::kNotSet; + } +} + +CompiledModelCompatibility ToCompiledModelCompatibility(OrtCompiledModelCompatibility compatibility) { + switch (compatibility) { + case OrtCompiledModelCompatibility_EP_SUPPORTED_OPTIMAL: + return CompiledModelCompatibility::kSupportedOptimal; + case OrtCompiledModelCompatibility_EP_SUPPORTED_PREFER_RECOMPILATION: + return CompiledModelCompatibility::kSupportedPreferRecompilation; + case OrtCompiledModelCompatibility_EP_UNSUPPORTED: + return CompiledModelCompatibility::kUnsupported; + case OrtCompiledModelCompatibility_EP_NOT_APPLICABLE: + default: + return CompiledModelCompatibility::kUnknown; + } +} + +} // namespace + EpDetector::EpDetector(const OrtApi& ort_api, OrtEnv& ort_env, std::vector> bootstrappers, ILogger& logger) @@ -41,52 +120,28 @@ std::map> EpDetector::GetAvailableDevicesT // running in parallel. std::map> devices; - // Query ORT for all registered EP devices. Each OrtEpDevice pairs an - // execution provider name with a hardware device (CPU/GPU/NPU). - const OrtEpDevice* const* ep_devices = nullptr; - size_t num_devices = 0; - OrtStatus* status = ort_api_.GetEpDevices(&ort_env_, &ep_devices, &num_devices); - - if (status != nullptr) { - const char* msg = ort_api_.GetErrorMessage(status); - logger_.Log(LogLevel::Warning, - std::string("GetEpDevices failed: ") + (msg ? msg : "unknown")); - ort_api_.ReleaseStatus(status); - + const auto snapshot = TryGetEpDeviceSnapshot(ort_api_, ort_env_, logger_); + if (!snapshot.has_value()) { // Fall back to a minimal CPU entry so catalog queries still work. devices["CPU"] = {"CPUExecutionProvider"}; return devices; } - logger_.Log(LogLevel::Debug, - std::string("GetEpDevices: ORT reports ") + std::to_string(num_devices) + " EP device(s)"); - - for (size_t i = 0; i < num_devices; ++i) { - const OrtEpDevice* ep_device = ep_devices[i]; + for (size_t i = 0; i < snapshot->devices.size(); ++i) { + const OrtEpDevice* ep_device = snapshot->devices[i]; const char* ep_name = ort_api_.EpDevice_EpName(ep_device); const OrtHardwareDevice* hw = ort_api_.EpDevice_Device(ep_device); - OrtHardwareDeviceType hw_type = ort_api_.HardwareDevice_Type(hw); - - const char* device_key = nullptr; - switch (hw_type) { - case OrtHardwareDeviceType_CPU: - device_key = "CPU"; - break; - case OrtHardwareDeviceType_GPU: - device_key = "GPU"; - break; - case OrtHardwareDeviceType_NPU: - device_key = "NPU"; - break; - default: - device_key = "CPU"; - break; - } + const auto hw_type = hw != nullptr ? ort_api_.HardwareDevice_Type(hw) : OrtHardwareDeviceType_CPU; + const char* device_key = DeviceKey(hw_type); logger_.Log(LogLevel::Debug, std::string(" [") + std::to_string(i) + "] ep=" + (ep_name ? ep_name : "") + " device=" + device_key + " (hw_type=" + std::to_string(static_cast(hw_type)) + ")"); + if (ep_name == nullptr) { + continue; + } + auto& eps = devices[device_key]; // Avoid duplicates (same EP can appear for multiple hardware instances). @@ -237,6 +292,78 @@ bool EpDetector::IsDownloadInProgress() const { return download_in_progress_; } +CompiledModelCompatibility EpDetector::GetModelCompatibilityForEpDevices( + std::string_view execution_provider, + std::optional device_type, + std::string_view compatibility_string) const { + if (execution_provider.empty() || compatibility_string.empty()) { + return CompiledModelCompatibility::kUnknown; + } + + // Collapse the optional into plain locals up front. Dereferencing it inside the device loop below trips a + // -Wmaybe-uninitialized false positive on GCC. + DeviceType required_device_type = DeviceType::kNotSet; + if (device_type.has_value()) { + required_device_type = *device_type; + } + + const bool filter_by_device = required_device_type != DeviceType::kNotSet; + + const auto snapshot = TryGetEpDeviceSnapshot(ort_api_, ort_env_, logger_); + if (!snapshot.has_value()) { + return CompiledModelCompatibility::kUnknown; + } + + std::vector matching_devices; + for (const OrtEpDevice* ep_device : snapshot->devices) { + if (ep_device == nullptr) { + continue; + } + + const char* ep_name = ort_api_.EpDevice_EpName(ep_device); + if (ep_name == nullptr || execution_provider != ep_name) { + continue; + } + + if (filter_by_device) { + const OrtHardwareDevice* hw_device = ort_api_.EpDevice_Device(ep_device); + const auto hw_type = hw_device != nullptr ? ort_api_.HardwareDevice_Type(hw_device) + : OrtHardwareDeviceType_CPU; + if (ToDeviceType(hw_type) != required_device_type) { + continue; + } + } + + matching_devices.push_back(ep_device); + } + + if (matching_devices.empty()) { + logger_.Log(LogLevel::Debug, + std::string("GetModelCompatibilityForEpDevices: no registered devices matched EP '") + + std::string(execution_provider) + "'."); + return CompiledModelCompatibility::kUnknown; + } + + std::string compatibility_copy(compatibility_string); + OrtCompiledModelCompatibility ort_compatibility = OrtCompiledModelCompatibility_EP_NOT_APPLICABLE; + OrtStatus* status = ort_api_.GetModelCompatibilityForEpDevices( + matching_devices.data(), + matching_devices.size(), + compatibility_copy.c_str(), + &ort_compatibility); + + if (status != nullptr) { + const char* message = ort_api_.GetErrorMessage(status); + logger_.Log(LogLevel::Warning, + std::string("GetModelCompatibilityForEpDevices failed for EP '") + + std::string(execution_provider) + "': " + (message ? message : "unknown")); + ort_api_.ReleaseStatus(status); + return CompiledModelCompatibility::kUnknown; + } + + return ToCompiledModelCompatibility(ort_compatibility); +} + bool EpDetector::PrepareForModelLoad(std::string_view ep_name) { auto it = std::find_if(bootstrappers_.begin(), bootstrappers_.end(), [&](const auto& bootstrapper) { return bootstrapper->Name() == ep_name; }); diff --git a/sdk_v2/cpp/src/ep_detection/ep_detector.h b/sdk_v2/cpp/src/ep_detection/ep_detector.h index d63d22abe..459a76131 100644 --- a/sdk_v2/cpp/src/ep_detection/ep_detector.h +++ b/sdk_v2/cpp/src/ep_detection/ep_detector.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,15 @@ struct OrtEnv; namespace fl { +enum class DeviceType : int; + +enum class CompiledModelCompatibility { + kUnknown = 0, + kSupportedOptimal, + kSupportedPreferRecompilation, + kUnsupported, +}; + class ILogger; /// Interface for detecting available hardware devices and execution providers. @@ -59,6 +69,16 @@ class IEpDetector { return EpDownloadResult{false, false, "EP download not supported", {}, {}}; } + /// Evaluate a precompiled model/package compatibility blob against the currently + /// registered OrtEpDevice instances for the requested EP and optional device class. + /// Default: unknown — callers must fail open on unknown results. + virtual CompiledModelCompatibility GetModelCompatibilityForEpDevices( + std::string_view /*execution_provider*/, + std::optional /*device_type*/, + std::string_view /*compatibility_string*/) const { + return CompiledModelCompatibility::kUnknown; + } + /// Whether an EP download/registration operation is currently in progress. /// Default: false. virtual bool IsDownloadInProgress() const { return false; } @@ -89,6 +109,10 @@ class EpDetector : public IEpDetector { std::span GetDiscoverableEpsCApi() const override; EpDownloadResult DownloadAndRegisterEps(const std::vector* names, const IEpBootstrapper::ProgressCallback& progress_cb) override; + CompiledModelCompatibility GetModelCompatibilityForEpDevices( + std::string_view execution_provider, + std::optional device_type, + std::string_view compatibility_string) const override; bool IsDownloadInProgress() const override; bool PrepareForModelLoad(std::string_view ep_name) override; 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 879f7c8cc..1f43cca19 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc +++ b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc @@ -19,38 +19,53 @@ GenAIModelInstance::GenAIModelInstance(std::string model_id, std::string effective_model_path, GenAIConfig genai_config, ExecutionProvider resolved_ep, + bool is_model_package, + bool is_multimodal, ILogger& logger) : model_id_(std::move(model_id)), model_path_(std::move(effective_model_path)), genai_config_(std::move(genai_config)), ep_(resolved_ep), + is_model_package_(is_model_package), + is_multimodal_(is_multimodal), last_activity_(std::chrono::steady_clock::now()) { - // Create OGA Config from the effective model directory std::unique_ptr oga_config; try { - oga_config = OgaConfig::Create(model_path_.c_str()); + if (is_model_package_) { + const auto provider = EPUtils::EPtoRegistrationName(ep_); + oga_config = OgaConfig::CreateFromPackageEp(model_path_.c_str(), provider.empty() ? nullptr : provider.data()); + } else { + oga_config = OgaConfig::Create(model_path_.c_str()); + } } catch (const std::runtime_error& e) { FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to create OGA config for model ", model_id_, ": ", e.what()); } - // Apply EP override to the OGA config - if (ep_ != ExecutionProvider::kDefault) { + // Package variant selection consumes the EP while creating the config. Mutating providers afterward would not + // reselect the package variant and could run a compiled graph with the wrong EP. + // kCPU is excluded because EPtoGenAI returns "" for it; an empty provider list already means CPU. + if (!is_model_package_ && ep_ != ExecutionProvider::kDefault && ep_ != ExecutionProvider::kCPU) { try { oga_config->ClearProviders(); 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) { - oga_config->SetProviderOption("cuda", "enable_cuda_graph", "0"); - } } catch (const std::runtime_error& e) { FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to configure EP for model ", model_id_, ": ", e.what()); } } + if (ep_ == ExecutionProvider::kCUDA) { + try { + // Provider options may be updated after package selection; only changing the provider list would invalidate it. + oga_config->SetProviderOption("cuda", "enable_cuda_graph", "0"); + } catch (const std::runtime_error& e) { + FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, + "failed to configure CUDA options for model ", model_id_, ": ", e.what()); + } + } + // Create OGA Model try { oga_model_ = OgaModel::Create(*oga_config); @@ -77,7 +92,7 @@ GenAIModelInstance::~GenAIModelInstance() = default; // --------------------------------------------------------------------------- bool GenAIModelInstance::IsMultiModal() const { - return genai_config_.model.has_value() && genai_config_.model->IsMultiModal(); + return is_multimodal_; } OgaModel& GenAIModelInstance::GetOgaModel() { diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h index 0893dc7ba..ff2cf173d 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h +++ b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h @@ -32,6 +32,7 @@ class GenAIModelInstance { const std::string& ModelPath() const { return model_path_; } const GenAIConfig& GetGenAIConfig() const { return genai_config_; } ExecutionProvider EP() const { return ep_; } + bool IsModelPackage() const { return is_model_package_; } bool IsMultiModal() const; /// Access the underlying OGA objects. @@ -55,12 +56,16 @@ class GenAIModelInstance { std::string effective_model_path, GenAIConfig genai_config, ExecutionProvider resolved_ep, + bool is_model_package, + bool is_multimodal, ILogger& logger); std::string model_id_; std::string model_path_; GenAIConfig genai_config_; ExecutionProvider ep_; + bool is_model_package_; + bool is_multimodal_; std::unique_ptr oga_model_; std::unique_ptr preprocessor_; std::chrono::steady_clock::time_point last_activity_; diff --git a/sdk_v2/cpp/src/inferencing/model_load_manager.cc b/sdk_v2/cpp/src/inferencing/model_load_manager.cc index a6e2defa8..3e66c4421 100644 --- a/sdk_v2/cpp/src/inferencing/model_load_manager.cc +++ b/sdk_v2/cpp/src/inferencing/model_load_manager.cc @@ -5,12 +5,16 @@ #include "exception.h" #include "inferencing/generative/genai_config.h" #include "inferencing/generative/genai_model_instance.h" +#include "util/model_layout.h" +#include "util/path_safety.h" #include "utils.h" #include #include #include +#include #include +#include #include namespace fl { @@ -20,6 +24,116 @@ namespace { /// The expected config filename inside a model directory. constexpr const char* kGenAIConfigFileName = "genai_config.json"; +int ConservativePositiveMinimum(int left, int right) { + if (left <= 0) { + return right; + } + + if (right <= 0) { + return left; + } + + return std::min(left, right); +} + +struct PackageRuntimeConfig { + GenAIConfig config; + bool is_multimodal = false; +}; + +PackageRuntimeConfig LoadPackageRuntimeConfig(const std::filesystem::path& package_root) { + const auto manifest_path = package_root / "manifest.json"; + std::ifstream manifest_file(manifest_path); + if (!manifest_file.is_open()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to open model package manifest: " + manifest_path.string()); + } + + nlohmann::json manifest; + try { + manifest_file >> manifest; + } catch (const nlohmann::json::exception& e) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to parse model package manifest: " + std::string(e.what())); + } + + if (!manifest.contains("components") || !manifest["components"].is_object() || + manifest["components"].size() != 1) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "Foundry Local Stage 1 requires a model package with exactly one inline component"); + } + + const auto component = manifest["components"].begin().value(); + if (!component.is_object() || !component.contains("variants") || !component["variants"].is_object() || + component["variants"].empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "model package component does not contain any variants"); + } + + PackageRuntimeConfig result; + bool has_config = false; + for (const auto& [variant_name, variant] : component["variants"].items()) { + if (!variant.is_object()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "model package variant '" + variant_name + "' must be an object"); + } + + std::filesystem::path variant_directory = variant_name; + if (variant.contains("variant_directory")) { + if (!variant["variant_directory"].is_string()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "model package variant '" + variant_name + "' has an invalid variant_directory"); + } + + variant_directory = variant["variant_directory"].get(); + } + + const auto variant_path = package_root / variant_directory; + if (!IsPathWithinDirectory(variant_path, package_root)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "model package variant '" + variant_name + "' resolves outside the package root"); + } + + auto config = GenAIConfig::LoadFromFile((variant_path / kGenAIConfigFileName).string()); + result.is_multimodal = result.is_multimodal || (config.model && config.model->IsMultiModal()); + if (!has_config) { + result.config = std::move(config); + has_config = true; + continue; + } + + if (result.config.model && config.model) { + if (result.config.model->type != config.model->type) { + result.config.model->type.clear(); + } + + result.config.model->context_length = + ConservativePositiveMinimum(result.config.model->context_length, config.model->context_length); + } else { + result.config.model.reset(); + } + + if (result.config.search && config.search) { + result.config.search->max_length = + ConservativePositiveMinimum(result.config.search->max_length, config.search->max_length); + } else { + result.config.search.reset(); + } + + if (result.config.hidden_size != config.hidden_size) { + result.config.hidden_size.reset(); + } + } + + if (result.config.model && result.config.model->context_length > 0 && + (!result.config.search || result.config.search->max_length <= 0)) { + result.config.search = GenAIConfig::Search{result.config.model->context_length}; + } + + return result; +} + +bool IsTaskMultiModal(std::string_view task) { + return task == "vision-language-chat" || task == "automatic-speech-recognition"; +} + /// Maps model_id substrings to their required execution provider registration name. /// If a model_id contains one of these keys, the corresponding EP must be registered. struct ModelIdEpRequirement { @@ -79,7 +193,8 @@ bool ModelLoadManager::HasEP(const std::string& ep_name) const { ModelLoadManager::LoadResult ModelLoadManager::LoadModel(std::string_view model_path, std::string_view model_id, - ExecutionProvider ep_override) { + ExecutionProvider ep_override, + std::string_view task) { if (shutdown_.load()) { FL_LOG_AND_THROW(logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "cannot load model during shutdown"); @@ -104,16 +219,28 @@ ModelLoadManager::LoadResult ModelLoadManager::LoadModel(std::string_view model_ logger_.Log(LogLevel::Debug, fmt::format("loading model from {}", path_str)); - // The caller provides the effective model path — the directory containing genai_config.json. - // DownloadManager and ScanLocalModels resolve this before passing it here. - auto config_path = (std::filesystem::path(path_str) / kGenAIConfigFileName).string(); + const auto layout = ClassifyModelLayout(path_str); + if (layout != ModelLayout::FlatModel && layout != ModelLayout::ModelPackage) { + FL_LOG_AND_THROW(logger_, FOUNDRY_LOCAL_ERROR_INTERNAL, "model has an unsupported or incomplete layout: ", id_str); + } - if (!std::filesystem::exists(config_path)) { - FL_LOG_AND_THROW(logger_, FOUNDRY_LOCAL_ERROR_INTERNAL, - "model does not contain ", kGenAIConfigFileName, ": ", id_str); + const bool is_model_package = layout == ModelLayout::ModelPackage; + GenAIConfig genai_config; + bool package_is_multimodal = false; + if (is_model_package) { + auto package_config = LoadPackageRuntimeConfig(path_str); + genai_config = std::move(package_config.config); + package_is_multimodal = package_config.is_multimodal; + } else { + genai_config = GenAIConfig::LoadFromFile( + (std::filesystem::path(path_str) / kGenAIConfigFileName).string()); } - auto genai_config = GenAIConfig::LoadFromFile(config_path); + // Task fallback is package-only: package merging can drop the model type, while flat models keep theirs. + // Some flat ASR models (e.g. nemotron_speech) use OgaStreamingProcessor, not OgaMultiModalProcessor. + const bool is_multimodal = package_is_multimodal || + (genai_config.model && genai_config.model->IsMultiModal()) || + (is_model_package && IsTaskMultiModal(task)); // Determine execution provider auto resolved_ep = ep_override; @@ -156,6 +283,8 @@ ModelLoadManager::LoadResult ModelLoadManager::LoadModel(std::string_view model_ path_str, std::move(genai_config), resolved_ep, + is_model_package, + is_multimodal, logger_)); auto* raw_ptr = loaded.get(); diff --git a/sdk_v2/cpp/src/inferencing/model_load_manager.h b/sdk_v2/cpp/src/inferencing/model_load_manager.h index f582217ad..01da8f6fa 100644 --- a/sdk_v2/cpp/src/inferencing/model_load_manager.h +++ b/sdk_v2/cpp/src/inferencing/model_load_manager.h @@ -46,14 +46,16 @@ class ModelLoadManager { ModelLoadManager& operator=(const ModelLoadManager&) = delete; /// Load a model from the given path using ORT GenAI. - /// @param model_path Path to the model directory (must contain genai_config.json). + /// @param model_path Path to a flat model directory or model package root. /// @param model_id Unique identifier for the model. /// @param ep_override Execution provider override (kDefault = use genai_config.json default, /// or auto-select CUDA for generic-gpu models if available). + /// @param task Catalog task used for package runtime metadata. /// @returns LoadResult with status and non-owning pointer to the loaded model. LoadResult LoadModel(std::string_view model_path, std::string_view model_id, - ExecutionProvider ep_override = ExecutionProvider::kDefault); + ExecutionProvider ep_override = ExecutionProvider::kDefault, + std::string_view task = {}); /// Unload a previously loaded model. /// @returns true if the model was found and unloaded; false if the model was not loaded diff --git a/sdk_v2/cpp/src/model.cc b/sdk_v2/cpp/src/model.cc index 6b2a81372..5eaf0ffd8 100644 --- a/sdk_v2/cpp/src/model.cc +++ b/sdk_v2/cpp/src/model.cc @@ -320,9 +320,20 @@ void Model::Load(ExecutionProvider ep) { return; } + // Only model packages need the catalog EP: it selects the package variant via + // OgaConfig::CreateFromPackageEp. Flat models keep kDefault so the load manager's own + // resolution still runs — notably the generic-gpu CUDA/WebGPU preference, which is skipped + // once the EP is no longer kDefault. + if (ep == ExecutionProvider::kDefault && info_.IsModelPackage() && !info_.execution_provider.empty()) { + const auto catalog_ep = EPUtils::StringtoEP(info_.execution_provider); + if (catalog_ep != ExecutionProvider::kUnknown) { + ep = catalog_ep; + } + } + // 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_, info_.model_id, ep, info_.task); if (result.status == ModelLoadManager::LoadStatus::kModelNotFound) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "model not found at path: " + local_path_); diff --git a/sdk_v2/cpp/src/model_info.cc b/sdk_v2/cpp/src/model_info.cc index cc2f68f16..a085e05ea 100644 --- a/sdk_v2/cpp/src/model_info.cc +++ b/sdk_v2/cpp/src/model_info.cc @@ -2,6 +2,8 @@ // Licensed under the MIT License. #include "model_info.h" +#include "util/string_utils.h" + #include #include @@ -25,24 +27,34 @@ std::string DeviceTypeToString(DeviceType dt) { } } -namespace { +DeviceType DeviceTypeFromString(std::string_view device_type) { + const auto normalized = ToLower(std::string(device_type)); -DeviceType DeviceTypeFromString(const std::string& s) { - if (s == "CPU") { + if (normalized == "cpu") { return DeviceType::kCPU; } - if (s == "GPU") { + if (normalized == "gpu") { return DeviceType::kGPU; } - if (s == "NPU") { + if (normalized == "npu") { return DeviceType::kNPU; } return DeviceType::kNotSet; } +namespace { + +std::string DeviceTypeToMetadataString(DeviceType device_type) { + return ToLower(DeviceTypeToString(device_type)); +} + +bool HasMeaningfulVariantMetadata(const ModelVariantMetadata& metadata) { + return metadata.model_format.has_value() || metadata.model_package.has_value(); +} + /// Read a string field from JSON into a string_properties entry. void ReadStringProp(const nlohmann::json& j, const char* json_key, std::map>& props, const char* prop_key) { @@ -72,6 +84,78 @@ void ReadBoolProp(const nlohmann::json& j, const char* json_key, } // anonymous namespace +void to_json(nlohmann::json& j, const ModelPackageVariant& variant) { + j = nlohmann::json{ + {"name", variant.name}, + {"executionProvider", variant.execution_provider}, + {"compatibilityString", variant.compatibility_string}, + }; + + if (variant.device_type != DeviceType::kNotSet) { + j["device"] = DeviceTypeToMetadataString(variant.device_type); + } +} + +void from_json(const nlohmann::json& j, ModelPackageVariant& variant) { + if (j.contains("name") && j["name"].is_string()) { + variant.name = j["name"].get(); + } + + if (j.contains("executionProvider") && j["executionProvider"].is_string()) { + variant.execution_provider = j["executionProvider"].get(); + } + + if (j.contains("device") && j["device"].is_string()) { + variant.device_type = DeviceTypeFromString(j["device"].get()); + } + + if (j.contains("compatibilityString") && j["compatibilityString"].is_string()) { + variant.compatibility_string = j["compatibilityString"].get(); + } +} + +void to_json(nlohmann::json& j, const ModelPackageMetadata& metadata) { + j = nlohmann::json{ + {"variants", metadata.variants}, + }; + + if (metadata.schema_version.has_value()) { + j["schemaVersion"] = *metadata.schema_version; + } +} + +void from_json(const nlohmann::json& j, ModelPackageMetadata& metadata) { + if (j.contains("schemaVersion") && j["schemaVersion"].is_number_integer()) { + metadata.schema_version = j["schemaVersion"].get(); + } + + if (j.contains("variants") && j["variants"].is_array()) { + metadata.variants = j["variants"].get>(); + } +} + +void to_json(nlohmann::json& j, const ModelVariantMetadata& metadata) { + j = nlohmann::json::object(); + + if (metadata.model_format.has_value()) { + j["modelFormat"] = *metadata.model_format; + } + + if (metadata.model_package.has_value()) { + j["modelPackage"] = *metadata.model_package; + } +} + +void from_json(const nlohmann::json& j, ModelVariantMetadata& metadata) { + if (j.contains("modelFormat") && j["modelFormat"].is_string()) { + metadata.model_format = j["modelFormat"].get(); + } + + if (j.contains("modelPackage") && j["modelPackage"].is_object()) { + metadata.model_package = j["modelPackage"].get(); + } +} + // --------------------------------------------------------------------------- // JSON deserialization for ModelInfo // Matches the AzureFoundryLocalModel JSON format from the server. @@ -106,6 +190,13 @@ ModelInfo ModelInfoFromJson(const nlohmann::json& j) { info.detected_region = j["detectedRegion"].get(); } + if (j.contains("variantMetadata") && j["variantMetadata"].is_object()) { + auto metadata = j["variantMetadata"].get(); + if (HasMeaningfulVariantMetadata(metadata)) { + info.variant_metadata = std::move(metadata); + } + } + // 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); @@ -208,6 +299,10 @@ nlohmann::json ModelInfoToJson(const ModelInfo& info) { j["detectedRegion"] = info.detected_region; } + if (info.variant_metadata.has_value() && HasMeaningfulVariantMetadata(*info.variant_metadata)) { + j["variantMetadata"] = *info.variant_metadata; + } + // providerType — required in C#, defaults to empty const auto* provider = info.GetPropertyStr(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR); j["providerType"] = provider ? *provider : ""; diff --git a/sdk_v2/cpp/src/model_info.h b/sdk_v2/cpp/src/model_info.h index 09f279545..2a213147b 100644 --- a/sdk_v2/cpp/src/model_info.h +++ b/sdk_v2/cpp/src/model_info.h @@ -9,8 +9,10 @@ #include #include #include +#include #include #include +#include namespace fl { @@ -19,7 +21,7 @@ namespace fl { // ----------------------------------------------------------------------- /// Device type the model is optimized for. Mirrors flDeviceType. -enum class DeviceType { +enum class DeviceType : int { kNotSet = 0, kCPU = 1, kGPU = 2, @@ -29,6 +31,27 @@ enum class DeviceType { /// Returns "CPU"/"GPU"/"NPU" or "Invalid" for kNotSet. std::string DeviceTypeToString(DeviceType dt); +/// Parses "CPU"/"GPU"/"NPU" (case-insensitive). Returns kNotSet if unknown. +DeviceType DeviceTypeFromString(std::string_view device_type); + +struct ModelPackageVariant { + std::string name; + std::string execution_provider; + DeviceType device_type = DeviceType::kNotSet; + std::string compatibility_string; +}; + +struct ModelPackageMetadata { + std::optional schema_version; + std::vector variants; +}; + +/// Internal representation of the provisional catalog `variantMetadata` payload. +struct ModelVariantMetadata { + std::optional model_format; + std::optional model_package; +}; + struct ModelInfo { std::string model_id; std::string name; @@ -45,6 +68,10 @@ struct ModelInfo { // registry when downloading. Round-trips through the on-disk catalog cache. std::string detected_region; + // Optional typed metadata for ORT model packages. This remains internal-only: + // the public catalog surface still exposes one model entry per model/version/EP. + std::optional variant_metadata; + KeyValuePairs prompt_templates; KeyValuePairs model_settings; @@ -55,6 +82,11 @@ struct ModelInfo { std::map> string_properties; std::map> int_properties; + /// Whether this entry describes an ORT model package rather than a flat model directory. + bool IsModelPackage() const { + return variant_metadata.has_value() && variant_metadata->model_package.has_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); @@ -81,6 +113,15 @@ struct ModelInfo { } }; +void to_json(nlohmann::json& j, const ModelPackageVariant& variant); +void from_json(const nlohmann::json& j, ModelPackageVariant& variant); + +void to_json(nlohmann::json& j, const ModelPackageMetadata& metadata); +void from_json(const nlohmann::json& j, ModelPackageMetadata& metadata); + +void to_json(nlohmann::json& j, const ModelVariantMetadata& metadata); +void from_json(const nlohmann::json& j, ModelVariantMetadata& metadata); + /// Deserialize a ModelInfo from JSON. ModelInfo ModelInfoFromJson(const nlohmann::json& j); diff --git a/sdk_v2/cpp/src/util/model_layout.cc b/sdk_v2/cpp/src/util/model_layout.cc new file mode 100644 index 000000000..6f7a119be --- /dev/null +++ b/sdk_v2/cpp/src/util/model_layout.cc @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "util/model_layout.h" + +#include + +namespace fl { + +namespace fs = std::filesystem; + +namespace { + +enum class FileProbe { + Absent, + RegularFile, + Other, + Error, +}; + +FileProbe ProbeRegularFile(const fs::path& path) { + std::error_code ec; + const auto status = fs::status(path, ec); + if (ec == std::errc::no_such_file_or_directory) { + return FileProbe::Absent; + } + + if (ec) { + return FileProbe::Error; + } + + if (!fs::exists(status)) { + return FileProbe::Absent; + } + + return fs::is_regular_file(status) ? FileProbe::RegularFile : FileProbe::Other; +} + +} // anonymous namespace + +ModelLayout ClassifyModelLayout(const fs::path& model_path) { + std::error_code ec; + const auto root_status = fs::status(model_path, ec); + if (ec || !fs::is_directory(root_status)) { + return ModelLayout::Invalid; + } + + const auto genai_config = ProbeRegularFile(model_path / "genai_config.json"); + if (genai_config == FileProbe::Error || genai_config == FileProbe::Other) { + return ModelLayout::Invalid; + } + + if (genai_config == FileProbe::RegularFile) { + return ModelLayout::FlatModel; + } + + const auto manifest = ProbeRegularFile(model_path / "manifest.json"); + if (manifest == FileProbe::Error || manifest == FileProbe::Other) { + return ModelLayout::Invalid; + } + + if (manifest == FileProbe::RegularFile) { + return ModelLayout::ModelPackage; + } + + return ModelLayout::Incomplete; +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/util/model_layout.h b/sdk_v2/cpp/src/util/model_layout.h new file mode 100644 index 000000000..6e657dcbe --- /dev/null +++ b/sdk_v2/cpp/src/util/model_layout.h @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include + +namespace fl { + +enum class ModelLayout { + FlatModel, + ModelPackage, + Incomplete, + Invalid, +}; + +/// Classify the model layout from its root identifying files. +/// +/// A model package has a regular manifest.json and no genai_config.json at the root. +/// A root genai_config.json always identifies a flat model, even when manifest.json is also present. +ModelLayout ClassifyModelLayout(const std::filesystem::path& model_path); + +} // namespace fl 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 18feea1b7..4ed8f990f 100644 --- a/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc +++ b/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc @@ -8,9 +8,12 @@ // #include "catalog/azure_catalog_client.h" #include "catalog/azure_catalog_models.h" +#include "catalog/azure_model_catalog.h" +#include "catalog/catalog_cache.h" #include "catalog/catalog_client.h" #include "ep_detection/ep_detector.h" #include "exception.h" +#include "internal_api/test_helpers.h" #include "logger.h" #include "model_info.h" @@ -18,10 +21,23 @@ #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + using namespace fl; namespace { +namespace fs = std::filesystem; + http::HttpResponse MakeOkResponse(std::string body) { http::HttpResponse response; response.status = 200; @@ -29,6 +45,246 @@ http::HttpResponse MakeOkResponse(std::string body) { return response; } +ModelPackageVariant MakePackageVariant(std::string name, + std::string execution_provider, + DeviceType device_type, + std::string compatibility_string) { + ModelPackageVariant variant; + variant.name = std::move(name); + variant.execution_provider = std::move(execution_provider); + variant.device_type = device_type; + variant.compatibility_string = std::move(compatibility_string); + return variant; +} + +ModelVariantMetadata MakeModelPackageMetadata(std::initializer_list variants, + std::string model_format = "ort-model-package", + std::optional schema_version = 1) { + ModelVariantMetadata metadata; + metadata.model_format = std::move(model_format); + + ModelPackageMetadata package; + package.schema_version = schema_version; + package.variants.assign(variants.begin(), variants.end()); + metadata.model_package = std::move(package); + + return metadata; +} + +ModelInfo MakeCatalogModelInfo(std::string model_id, + std::string name, + int version, + std::string alias, + std::string execution_provider = "CPUExecutionProvider", + DeviceType device_type = DeviceType::kCPU, + std::optional variant_metadata = std::nullopt) { + ModelInfo info; + info.model_id = std::move(model_id); + info.name = std::move(name); + info.version = version; + info.alias = std::move(alias); + info.uri = "azureml://test/" + info.model_id; + info.execution_provider = std::move(execution_provider); + info.device_type = device_type; + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR] = "FoundryLocal"; + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR] = "ONNX"; + info.variant_metadata = std::move(variant_metadata); + return info; +} + +Model MakeTestModel(ModelInfo info, std::string local_path) { + static fl::test::FakeServiceBindings services; + return Model::FromModelInfo(std::move(info), + std::move(local_path), + services.download_manager, + services.model_load_manager); +} + +struct FakeCatalogClientState { + std::vector all_models; + std::vector id_models; + std::vector version_models; + int fetch_all_calls = 0; + int fetch_models_by_id_calls = 0; + int fetch_versions_calls = 0; + std::vector> requested_id_batches; +}; + +class FakeCatalogClient : public ICatalogClient { + public: + explicit FakeCatalogClient(std::shared_ptr state) + : state_(std::move(state)) {} + + std::vector FetchAllModelInfos() override { + ++state_->fetch_all_calls; + return state_->all_models; + } + + std::vector FetchModelsByIds(const std::vector& model_ids) override { + ++state_->fetch_models_by_id_calls; + state_->requested_id_batches.push_back(model_ids); + + std::vector result; + for (const auto& info : state_->id_models) { + if (std::find(model_ids.begin(), model_ids.end(), info.model_id) != model_ids.end()) { + result.push_back(info); + } + } + + return result; + } + + std::vector FetchAllVersionsByAlias(const std::string& model_alias, + const std::string& model_name, + int /*max_versions*/) override { + ++state_->fetch_versions_calls; + + std::vector result; + for (const auto& info : state_->version_models) { + if (info.alias != model_alias) { + continue; + } + + if (!model_name.empty() && info.name != model_name) { + continue; + } + + result.push_back(info); + } + + return result; + } + + private: + std::shared_ptr state_; +}; + +/// Catalog that serves every request from an in-memory FakeCatalogClient. +class FakeClientAzureModelCatalog final : public AzureModelCatalog { + public: + FakeClientAzureModelCatalog(std::vector>> catalog_urls, + std::string cache_dir, + ModelFactory model_factory, + const IEpDetector& ep_detector, + ILogger& logger, + bool cache_only, + std::shared_ptr state) + : AzureModelCatalog(std::move(catalog_urls), std::move(cache_dir), std::move(model_factory), ep_detector, logger, + cache_only, "eastus", false), + state_(std::move(state)) {} + + protected: + std::unique_ptr CreateCatalogClient(const std::string& /*url*/, + const std::string& /*filter*/) const override { + return std::make_unique(state_); + } + + private: + std::shared_ptr state_; +}; + +class CompatibilityEpDetector : public IEpDetector { + public: + struct Call { + std::string execution_provider; + std::optional device_type; + std::string compatibility_string; + }; + + explicit CompatibilityEpDetector( + std::map compatibilities, + std::map> devices = {{"CPU", {"CPUExecutionProvider"}}}) + : compatibilities_(std::move(compatibilities)), + devices_(std::move(devices)) {} + + std::map> GetAvailableDevicesToEPs() const override { + return devices_; + } + + CompiledModelCompatibility GetModelCompatibilityForEpDevices( + std::string_view execution_provider, + std::optional device_type, + std::string_view compatibility_string) const override { + calls_.push_back(Call{ + std::string(execution_provider), + device_type, + std::string(compatibility_string), + }); + + const auto it = compatibilities_.find(std::string(compatibility_string)); + if (it != compatibilities_.end()) { + return it->second; + } + + return CompiledModelCompatibility::kUnknown; + } + + mutable std::vector calls_; + + private: + std::map compatibilities_; + std::map> devices_; +}; + +class AzureModelCatalogCompatibilityTest : public ::testing::Test { + protected: + void SetUp() override { + const auto unique_suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + test_dir_ = (fs::temp_directory_path() / + ("fl_azure_model_catalog_" + std::to_string(unique_suffix))).string(); + fs::create_directories(test_dir_); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(test_dir_, ec); + } + + std::unique_ptr MakeCatalog( + const IEpDetector& detector, + bool cache_only, + std::shared_ptr state = std::make_shared()) { + return std::make_unique( + std::vector>>{ + {"https://test.com", std::nullopt}, + }, + test_dir_, + MakeTestModel, + detector, + logger_, + cache_only, + std::move(state)); + } + + void SaveCache(const std::vector& models) { + CatalogCache cache(test_dir_, logger_); + cache.Save(models); + } + + void CreateScannedFlatModel(const std::string& model_id) { + const fs::path model_root = fs::path(test_dir_) / "publisher" / "model"; + fs::create_directories(model_root); + + { + std::ofstream genai_config(model_root / "genai_config.json", std::ios::trunc); + ASSERT_TRUE(genai_config.is_open()); + genai_config << "{}"; + } + + { + nlohmann::json inference_model; + inference_model["Name"] = model_id; + + std::ofstream file(model_root / "inference_model.json", std::ios::trunc); + ASSERT_TRUE(file.is_open()); + file << inference_model.dump(2); + } + } + + std::string test_dir_; + StderrLogger logger_; +}; + } // namespace // ======================================================================== @@ -260,6 +516,88 @@ TEST(AzureCatalogClientTest, ParsesModelResponseCorrectly) { EXPECT_EQ(info.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT), 4096); // 4GB → 4096 MB } +TEST(AzureCatalogClientTest, ParsesOrtModelPackageMetadataIntoTypedModelInfo) { + CpuOnlyEpDetector ep; + StderrLogger logger; + + const char* mock_response = R"({ + "indexEntitiesResponse": { + "totalCount": 1, + "value": [{ + "assetId": "azureml://registries/azureml/models/phi-4-mini-ort-package/versions/1", + "entityId": "phi-4-mini-ort-package:1", + "annotations": { + "tags": { + "alias": "phi-4-mini-ort-package" + } + }, + "properties": { + "name": "phi-4-mini-ort-package", + "version": 1, + "variantInfo": { + "parents": [], + "variantMetadata": { + "modelType": "ONNX", + "device": "cpu", + "executionProvider": "CPUExecutionProvider", + "modelFormat": "ort-model-package", + "modelPackage": { + "schemaVersion": 7, + "variants": [ + { + "name": "cpu-compiled", + "executionProvider": "CPUExecutionProvider", + "device": "cpu", + "compatibilityString": "compat-a" + }, + { + "name": "gpu-compiled", + "executionProvider": "CUDAExecutionProvider", + "device": "gpu", + "compatibilityString": "compat-b" + } + ] + } + } + } + } + }], + "nextSkip": 0, + "continuationToken": "" + } + })"; + + AzureCatalogClient client("https://test.com", "", ep, logger, + [&](const std::string&, const std::string&) { + return MakeOkResponse(mock_response); + }); + + auto model_infos = client.FetchAllModelInfos(); + ASSERT_EQ(model_infos.size(), 1u); + + const auto& info = model_infos[0]; + ASSERT_TRUE(info.variant_metadata.has_value()); + ASSERT_TRUE(info.variant_metadata->model_format.has_value()); + EXPECT_EQ(*info.variant_metadata->model_format, "ort-model-package"); + + ASSERT_TRUE(info.variant_metadata->model_package.has_value()); + ASSERT_TRUE(info.variant_metadata->model_package->schema_version.has_value()); + EXPECT_EQ(*info.variant_metadata->model_package->schema_version, 7); + + const auto& variants = info.variant_metadata->model_package->variants; + ASSERT_EQ(variants.size(), 2u); + + EXPECT_EQ(variants[0].name, "cpu-compiled"); + EXPECT_EQ(variants[0].execution_provider, "CPUExecutionProvider"); + EXPECT_EQ(variants[0].device_type, DeviceType::kCPU); + EXPECT_EQ(variants[0].compatibility_string, "compat-a"); + + EXPECT_EQ(variants[1].name, "gpu-compiled"); + EXPECT_EQ(variants[1].execution_provider, "CUDAExecutionProvider"); + EXPECT_EQ(variants[1].device_type, DeviceType::kGPU); + EXPECT_EQ(variants[1].compatibility_string, "compat-b"); +} + // Verify that invalid models (missing required fields) are filtered out TEST(AzureCatalogClientTest, SkipsInvalidModels) { CpuOnlyEpDetector ep; @@ -1091,3 +1429,245 @@ TEST(AzureCatalogClientTest, Fallback_MidPaginationFailureDoesNotCommitPartialFi EXPECT_EQ(calls, 2); EXPECT_TRUE(models.empty()); } + +// ======================================================================== +// AzureModelCatalog compatibility filtering tests +// ======================================================================== + +TEST_F(AzureModelCatalogCompatibilityTest, NormalListShowsModelWhenAnyRelevantPackageVariantIsCompatible) { + auto state = std::make_shared(); + state->all_models.push_back(MakeCatalogModelInfo( + "compatible-model:1", + "compatible-model", + 1, + "compatible-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "unsupported-cpu"), + MakePackageVariant("cpu-b", "CPUExecutionProvider", DeviceType::kCPU, "prefer-recompile"), + }))); + + CompatibilityEpDetector detector({ + {"unsupported-cpu", CompiledModelCompatibility::kUnsupported}, + {"prefer-recompile", CompiledModelCompatibility::kSupportedPreferRecompilation}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + auto models = catalog->ListModels(); + + ASSERT_EQ(models.size(), 1u); + EXPECT_EQ(models[0]->Info().model_id, "compatible-model:1"); + ASSERT_EQ(detector.calls_.size(), 2u); + EXPECT_EQ(detector.calls_[0].execution_provider, "CPUExecutionProvider"); + ASSERT_TRUE(detector.calls_[0].device_type.has_value()); + EXPECT_EQ(*detector.calls_[0].device_type, DeviceType::kCPU); +} + +TEST_F(AzureModelCatalogCompatibilityTest, NormalListHidesModelWhenAllRelevantPackageVariantsUnsupported) { + auto state = std::make_shared(); + state->all_models.push_back(MakeCatalogModelInfo( + "unsupported-model:1", + "unsupported-model", + 1, + "unsupported-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "unsupported-a"), + MakePackageVariant("cpu-b", "CPUExecutionProvider", DeviceType::kCPU, "unsupported-b"), + MakePackageVariant("gpu-a", "CUDAExecutionProvider", DeviceType::kGPU, "supported-but-irrelevant"), + }))); + + CompatibilityEpDetector detector({ + {"unsupported-a", CompiledModelCompatibility::kUnsupported}, + {"unsupported-b", CompiledModelCompatibility::kUnsupported}, + {"supported-but-irrelevant", CompiledModelCompatibility::kSupportedOptimal}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + auto models = catalog->ListModels(); + + EXPECT_TRUE(models.empty()); + ASSERT_EQ(detector.calls_.size(), 2u); +} + +TEST_F(AzureModelCatalogCompatibilityTest, NormalListFailsOpenForNotApplicableAndEmptyCompatibilityString) { + auto state = std::make_shared(); + state->all_models.push_back(MakeCatalogModelInfo( + "not-applicable-model:1", + "not-applicable-model", + 1, + "not-applicable-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "not-applicable"), + }))); + state->all_models.push_back(MakeCatalogModelInfo( + "empty-compat-model:1", + "empty-compat-model", + 1, + "empty-compat-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, ""), + }))); + + CompatibilityEpDetector detector({ + {"not-applicable", CompiledModelCompatibility::kUnknown}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + auto models = catalog->ListModels(); + + ASSERT_EQ(models.size(), 2u); + ASSERT_EQ(detector.calls_.size(), 1u); + EXPECT_EQ(detector.calls_[0].compatibility_string, "not-applicable"); +} + +TEST_F(AzureModelCatalogCompatibilityTest, NormalListLeavesFlatModelsVisible) { + auto state = std::make_shared(); + + ModelVariantMetadata flat_metadata; + flat_metadata.model_format = "flat"; + + state->all_models.push_back(MakeCatalogModelInfo( + "flat-model:1", + "flat-model", + 1, + "flat-model", + "CPUExecutionProvider", + DeviceType::kCPU, + flat_metadata)); + + CompatibilityEpDetector detector({ + {"unused", CompiledModelCompatibility::kUnsupported}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + auto models = catalog->ListModels(); + + ASSERT_EQ(models.size(), 1u); + EXPECT_EQ(models[0]->Info().model_id, "flat-model:1"); + EXPECT_TRUE(detector.calls_.empty()); +} + +TEST_F(AzureModelCatalogCompatibilityTest, CacheOnlyModeRefiltersCachedMetadataOnEachRead) { + SaveCache({ + MakeCatalogModelInfo( + "cached-package:1", + "cached-package", + 1, + "cached-package", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "cached-compat"), + })), + }); + + CompatibilityEpDetector unsupported_detector({ + {"cached-compat", CompiledModelCompatibility::kUnsupported}, + }); + auto hidden_catalog = MakeCatalog(unsupported_detector, /*cache_only=*/true); + EXPECT_TRUE(hidden_catalog->ListModels().empty()); + + CompatibilityEpDetector supported_detector({ + {"cached-compat", CompiledModelCompatibility::kSupportedOptimal}, + }); + auto visible_catalog = MakeCatalog(supported_detector, /*cache_only=*/true); + auto visible_models = visible_catalog->ListModels(); + + ASSERT_EQ(visible_models.size(), 1u); + EXPECT_EQ(visible_models[0]->Info().model_id, "cached-package:1"); +} + +TEST_F(AzureModelCatalogCompatibilityTest, FilteredCachedCatalogModelIsNotSynthesizedAsLocalByo) { + CreateScannedFlatModel("cached-package:1"); + + auto state = std::make_shared(); + state->id_models.push_back(MakeCatalogModelInfo( + "cached-package:1", + "cached-package", + 1, + "cached-package", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "cached-hidden"), + }))); + + CompatibilityEpDetector detector({ + {"cached-hidden", CompiledModelCompatibility::kUnsupported}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + auto models = catalog->ListModels(); + + EXPECT_TRUE(models.empty()); + EXPECT_EQ(state->fetch_all_calls, 1); + EXPECT_EQ(state->fetch_models_by_id_calls, 1); + ASSERT_EQ(state->requested_id_batches.size(), 1u); + ASSERT_EQ(state->requested_id_batches[0].size(), 1u); + EXPECT_EQ(state->requested_id_batches[0][0], "cached-package:1"); +} + +TEST_F(AzureModelCatalogCompatibilityTest, DirectIdLookupFiltersUnsupportedPackageModel) { + auto state = std::make_shared(); + state->id_models.push_back(MakeCatalogModelInfo( + "lookup-model:1", + "lookup-model", + 1, + "lookup-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "lookup-hidden"), + }))); + + CompatibilityEpDetector detector({ + {"lookup-hidden", CompiledModelCompatibility::kUnsupported}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + EXPECT_EQ(catalog->GetModelVariant("lookup-model:1"), nullptr); + EXPECT_EQ(state->fetch_models_by_id_calls, 1); +} + +TEST_F(AzureModelCatalogCompatibilityTest, VersionListingFiltersUnsupportedPackageVersions) { + auto state = std::make_shared(); + state->version_models.push_back(MakeCatalogModelInfo( + "versioned-model:1", + "versioned-model", + 1, + "versioned-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "version-hidden"), + }))); + state->version_models.push_back(MakeCatalogModelInfo( + "versioned-model:2", + "versioned-model", + 2, + "versioned-model", + "CPUExecutionProvider", + DeviceType::kCPU, + MakeModelPackageMetadata({ + MakePackageVariant("cpu-a", "CPUExecutionProvider", DeviceType::kCPU, "version-visible"), + }))); + + CompatibilityEpDetector detector({ + {"version-hidden", CompiledModelCompatibility::kUnsupported}, + {"version-visible", CompiledModelCompatibility::kSupportedOptimal}, + }); + + auto catalog = MakeCatalog(detector, /*cache_only=*/false, state); + auto versions = catalog->GetModelVersions("versioned-model", "", 0); + + ASSERT_EQ(versions.size(), 1u); + EXPECT_EQ(versions[0]->Info().model_id, "versioned-model:2"); + EXPECT_EQ(state->fetch_versions_calls, 1); +} diff --git a/sdk_v2/cpp/test/internal_api/catalog_cache_test.cc b/sdk_v2/cpp/test/internal_api/catalog_cache_test.cc index c46cd4dc5..a517d15bc 100644 --- a/sdk_v2/cpp/test/internal_api/catalog_cache_test.cc +++ b/sdk_v2/cpp/test/internal_api/catalog_cache_test.cc @@ -137,6 +137,53 @@ TEST_F(CatalogCacheTest, DetectedRegionRoundTrip) { } } +TEST_F(CatalogCacheTest, VariantMetadataRoundTrip) { + ModelInfo model = MakeTestModel("phi-4-mini:3", 3); + + ModelPackageVariant package_variant; + package_variant.name = "cpu-compiled"; + package_variant.execution_provider = "CPUExecutionProvider"; + package_variant.device_type = DeviceType::kCPU; + package_variant.compatibility_string = "compat-a"; + + ModelPackageMetadata package_metadata; + package_metadata.schema_version = 5; + package_metadata.variants = {package_variant}; + + ModelVariantMetadata variant_metadata; + variant_metadata.model_format = "ort-model-package"; + variant_metadata.model_package = package_metadata; + model.variant_metadata = variant_metadata; + + { + CatalogCache cache(test_dir_, logger_); + cache.Save({model}); + } + + { + CatalogCache cache(test_dir_, logger_); + cache.Load(); + auto loaded = cache.GetCachedModels(); + + ASSERT_TRUE(loaded.has_value()); + ASSERT_EQ(loaded->size(), 1u); + ASSERT_TRUE((*loaded)[0].variant_metadata.has_value()); + ASSERT_TRUE((*loaded)[0].variant_metadata->model_format.has_value()); + EXPECT_EQ(*(*loaded)[0].variant_metadata->model_format, "ort-model-package"); + + ASSERT_TRUE((*loaded)[0].variant_metadata->model_package.has_value()); + ASSERT_TRUE((*loaded)[0].variant_metadata->model_package->schema_version.has_value()); + EXPECT_EQ(*(*loaded)[0].variant_metadata->model_package->schema_version, 5); + + const auto& variants = (*loaded)[0].variant_metadata->model_package->variants; + ASSERT_EQ(variants.size(), 1u); + EXPECT_EQ(variants[0].name, "cpu-compiled"); + EXPECT_EQ(variants[0].execution_provider, "CPUExecutionProvider"); + EXPECT_EQ(variants[0].device_type, DeviceType::kCPU); + EXPECT_EQ(variants[0].compatibility_string, "compat-a"); + } +} + TEST_F(CatalogCacheTest, EmptyModelListRoundTrip) { std::vector empty_models; diff --git a/sdk_v2/cpp/test/internal_api/download_test.cc b/sdk_v2/cpp/test/internal_api/download_test.cc index d8a5f9b5c..6cf6e0d5a 100644 --- a/sdk_v2/cpp/test/internal_api/download_test.cc +++ b/sdk_v2/cpp/test/internal_api/download_test.cc @@ -19,6 +19,7 @@ #include "logger.h" #include "model_info.h" #include "test_helpers.h" +#include "util/model_layout.h" #include "util/path_safety.h" #include "util/region_fallback.h" #include @@ -31,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -69,6 +71,7 @@ class MockBlobDownloader : public IBlobDownloader { public: std::vector blobs_to_return; std::vector downloaded_blobs; // names of blobs that were "downloaded" + std::map blob_contents; std::string expected_sas_uri; std::vector ListBlobs(const std::string& sas_uri) override { @@ -91,7 +94,12 @@ class MockBlobDownloader : public IBlobDownloader { fs::create_directories(parent); } std::ofstream f(local_path); - f << "mock content for " << blob_name; + const auto content = blob_contents.find(blob_name); + if (content != blob_contents.end()) { + f << content->second; + } else { + f << "mock content for " << blob_name; + } // Report byte count for progress tracking (content_length from the matching blob) if (bytes_written_cb) { @@ -754,6 +762,45 @@ TEST(InferenceModelWriterTest, WritesNullPromptTemplateWhenEmpty) { EXPECT_TRUE(j["PromptTemplate"].is_null()); } +// ======================================================================== +// Model layout classifier tests +// ======================================================================== + +TEST(ModelLayoutTest, ManifestWithoutRootGenaiConfigIsModelPackage) { + auto tmpdir = TempPath::CreateTempDir(); + std::ofstream(tmpdir.path() / "manifest.json") << "{}"; + + EXPECT_EQ(ClassifyModelLayout(tmpdir.path()), ModelLayout::ModelPackage); +} + +TEST(ModelLayoutTest, ManifestWithRootGenaiConfigIsFlatModel) { + auto tmpdir = TempPath::CreateTempDir(); + std::ofstream(tmpdir.path() / "manifest.json") << "{}"; + std::ofstream(tmpdir.path() / "genai_config.json") << "{}"; + + EXPECT_EQ(ClassifyModelLayout(tmpdir.path()), ModelLayout::FlatModel); +} + +TEST(ModelLayoutTest, NonRegularGenaiConfigDoesNotCountAsAbsent) { + auto tmpdir = TempPath::CreateTempDir(); + std::ofstream(tmpdir.path() / "manifest.json") << "{}"; + fs::create_directory(tmpdir.path() / "genai_config.json"); + + EXPECT_EQ(ClassifyModelLayout(tmpdir.path()), ModelLayout::Invalid); +} + +TEST(ModelLayoutTest, DirectoryWithoutIdentifyingFilesIsIncomplete) { + auto tmpdir = TempPath::CreateTempDir(); + + EXPECT_EQ(ClassifyModelLayout(tmpdir.path()), ModelLayout::Incomplete); +} + +TEST(ModelLayoutTest, RegularFilePathIsInvalid) { + auto tmpfile = TempPath::CreateTempFile(); + + EXPECT_EQ(ClassifyModelLayout(tmpfile.path()), ModelLayout::Invalid); +} + // ======================================================================== // Variant fixup tests // ======================================================================== @@ -840,6 +887,21 @@ TEST(VariantFixupTest, PreservesRootFileWhenNoSubdirs) { EXPECT_EQ(j["Name"], "root-only"); } +TEST(VariantFixupTest, ModelPackageKeepsInferenceModelAtRoot) { + auto tmpdir = TempPath::CreateTempDir(); + const auto& root = tmpdir.path(); + std::ofstream(root / "manifest.json") << "{}"; + std::ofstream(root / "inference_model.json") << R"({"Name": "package"})"; + fs::create_directories(root / "variants" / "cpu"); + fs::create_directories(root / "shared_assets"); + + FixVariantInferenceModelJson(root.string()); + + EXPECT_TRUE(fs::is_regular_file(root / "inference_model.json")); + EXPECT_FALSE(fs::exists(root / "variants" / "inference_model.json")); + EXPECT_FALSE(fs::exists(root / "shared_assets" / "inference_model.json")); +} + // ======================================================================== // DownloadManager tests // ======================================================================== @@ -892,6 +954,69 @@ TEST(DownloadManagerTest, FullDownloadFlow) { EXPECT_FALSE(progress_values.empty()); } +TEST(DownloadManagerTest, DownloadedModelPackageWritesRootMarkerAndReturnsRootPath) { + auto tmpdir = TempPath::CreateTempDir(); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + + const std::string sas_uri = "https://storage.blob.core.windows.net/package?sig=test"; + auto registry = std::make_unique( + "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), + [](const std::string&) { + return MakeRegistryResponse( + R"({"blobSasUri": "https://storage.blob.core.windows.net/package?sig=test"})"); + }); + manager.SetModelRegistryClient(std::move(registry)); + + const std::string manifest = R"({ + "schema_version": "1.0", + "components": { + "model": { + "variants": { + "cpu": { "ep": "CPUExecutionProvider" } + } + } + } + })"; + const std::string genai_config = R"({"model": {"type": "decoder-pipeline"}})"; + const std::string placeholder_model = "placeholder model"; + + auto mock_downloader = std::make_unique(); + mock_downloader->expected_sas_uri = sas_uri; + mock_downloader->blobs_to_return = { + {"manifest.json", static_cast(manifest.size())}, + {"cpu/genai_config.json", static_cast(genai_config.size())}, + {"cpu/model.onnx", static_cast(placeholder_model.size())}, + }; + mock_downloader->blob_contents = { + {"manifest.json", manifest}, + {"cpu/genai_config.json", genai_config}, + {"cpu/model.onnx", placeholder_model}, + }; + manager.SetBlobDownloader(std::move(mock_downloader)); + + ModelInfo info; + info.model_id = "package-model:1"; + info.uri = "azureml://registries/test/models/package-model/versions/1"; + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR] = "TestPublisher"; + + const auto package_root = tmpdir.path() / "TestPublisher" / "package-model-1"; + const auto variant_path = package_root / "cpu"; + const auto downloaded_path = manager.DownloadModel(info); + + EXPECT_EQ(fs::path(downloaded_path), package_root); + EXPECT_EQ(ClassifyModelLayout(package_root), ModelLayout::ModelPackage); + EXPECT_TRUE(fs::is_regular_file(package_root / "manifest.json")); + ASSERT_TRUE(fs::is_regular_file(package_root / "inference_model.json")); + EXPECT_TRUE(fs::is_regular_file(variant_path / "genai_config.json")); + EXPECT_TRUE(fs::is_regular_file(variant_path / "model.onnx")); + + const auto marker = nlohmann::json::parse(ReadFile(package_root / "inference_model.json")); + EXPECT_EQ(marker["Name"], info.model_id); + EXPECT_FALSE(fs::exists(variant_path / "inference_model.json")); + EXPECT_FALSE(fs::exists(package_root / "download.tmp")); + EXPECT_TRUE(manager.IsModelCached(info)); +} + // --- Region resolution: detected region drives the download endpoint --- // Run one download and return the registry URL the manager hit. @@ -1040,6 +1165,59 @@ TEST(DownloadManagerTest, IsModelCachedReturnsFalseForEmptyDir) { EXPECT_FALSE(manager.IsModelCached(info)); } +TEST(DownloadManagerTest, CachedModelPackageUsesRootMarkerAndReturnsRootPath) { + auto tmpdir = TempPath::CreateTempDir(); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + + ModelInfo info; + info.model_id = "package:1"; + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR] = "Publisher"; + + auto model_dir = fs::path(tmpdir.string()) / "Publisher" / "package-1"; + fs::create_directories(model_dir / "variants" / "cpu"); + std::ofstream(model_dir / "manifest.json") << "{}"; + std::ofstream(model_dir / "inference_model.json") << R"({"Name": "package:1"})"; + std::ofstream(model_dir / "variants" / "cpu" / "genai_config.json") << "{}"; + + EXPECT_TRUE(manager.IsModelCached(info)); + EXPECT_EQ(manager.DownloadModel(info), model_dir.string()); +} + +TEST(DownloadManagerTest, ModelPackageWithoutRootMarkerIsNotCached) { + auto tmpdir = TempPath::CreateTempDir(); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + + ModelInfo info; + info.model_id = "package:2"; + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR] = "Publisher"; + + auto model_dir = fs::path(tmpdir.string()) / "Publisher" / "package-2"; + fs::create_directories(model_dir / "variants" / "cpu"); + std::ofstream(model_dir / "manifest.json") << "{}"; + std::ofstream(model_dir / "variants" / "cpu" / "genai_config.json") << "{}"; + std::ofstream(model_dir / "variants" / "cpu" / "inference_model.json") << "{}"; + + EXPECT_FALSE(manager.IsModelCached(info)); +} + +TEST(DownloadManagerTest, LegacyFlatVariantReturnsFirstChildWithGenaiConfig) { + auto tmpdir = TempPath::CreateTempDir(); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + + ModelInfo info; + info.model_id = "flat-variant:1"; + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR] = "Publisher"; + + auto model_dir = fs::path(tmpdir.string()) / "Publisher" / "flat-variant-1"; + auto variant_dir = model_dir / "cpu"; + fs::create_directories(variant_dir); + std::ofstream(variant_dir / "genai_config.json") << "{}"; + std::ofstream(variant_dir / "inference_model.json") << R"({"Name": "flat-variant:1"})"; + + EXPECT_TRUE(manager.IsModelCached(info)); + EXPECT_EQ(manager.DownloadModel(info), variant_dir.string()); +} + TEST(DownloadManagerTest, VersionSuffixConversion) { auto tmpdir = TempPath::CreateTempDir(); DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); diff --git a/sdk_v2/cpp/test/internal_api/local_model_scanner_test.cc b/sdk_v2/cpp/test/internal_api/local_model_scanner_test.cc index 9c577258c..a8cb8b935 100644 --- a/sdk_v2/cpp/test/internal_api/local_model_scanner_test.cc +++ b/sdk_v2/cpp/test/internal_api/local_model_scanner_test.cc @@ -138,6 +138,53 @@ TEST_F(LocalModelScannerTest, MissingInferenceModelExcluded) { EXPECT_TRUE(results.empty()); } +TEST_F(LocalModelScannerTest, ModelPackageRootDetectedOnceWithoutScanningChildren) { + CreateFile("publisher/package/manifest.json"); + CreateFile("publisher/package/inference_model.json", R"({"Name": "package-model:7"})"); + CreateModelDir("publisher/package/variants/cpu", "child-model:1"); + CreateModelDir("publisher/package/shared_assets/tokenizer", "shared-asset:1"); + + auto results = ScanLocalModels(test_dir_, logger_); + + ASSERT_EQ(results.size(), 1u); + ASSERT_TRUE(results.contains("package-model:7")); + EXPECT_EQ(results.at("package-model:7"), (fs::path(test_dir_) / "publisher" / "package").string()); + EXPECT_FALSE(results.contains("child-model:1")); + EXPECT_FALSE(results.contains("shared-asset:1")); +} + +TEST_F(LocalModelScannerTest, FlatModelWithUnrelatedManifestRemainsFlat) { + CreateModelDir("publisher/flat-model", "flat-model:2"); + CreateFile("publisher/flat-model/manifest.json"); + CreateModelDir("publisher/flat-model/nested", "nested-model:1"); + + auto results = ScanLocalModels(test_dir_, logger_); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results.at("flat-model:2"), (fs::path(test_dir_) / "publisher" / "flat-model").string()); + EXPECT_FALSE(results.contains("nested-model:1")); +} + +TEST_F(LocalModelScannerTest, ModelPackageWithDownloadSignalIsExcludedWithoutScanningChildren) { + CreateFile("publisher/package/manifest.json"); + CreateFile("publisher/package/inference_model.json", R"({"Name": "package-model:1"})"); + CreateFile("publisher/package/download.tmp", ""); + CreateModelDir("publisher/package/variants/cpu", "child-model:1"); + + auto results = ScanLocalModels(test_dir_, logger_); + + EXPECT_TRUE(results.empty()); +} + +TEST_F(LocalModelScannerTest, ModelPackageWithoutRootInferenceMarkerIsExcludedWithoutScanningChildren) { + CreateFile("publisher/package/manifest.json"); + CreateModelDir("publisher/package/variants/cpu", "child-model:1"); + + auto results = ScanLocalModels(test_dir_, logger_); + + EXPECT_TRUE(results.empty()); +} + TEST_F(LocalModelScannerTest, NestedPublisherModelStructure) { CreateModelDir("microsoft/phi-4-mini-instruct", "phi-4-mini-instruct:2"); 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..6ce22bd54 100644 --- a/sdk_v2/cpp/test/internal_api/model_info_test.cc +++ b/sdk_v2/cpp/test/internal_api/model_info_test.cc @@ -42,6 +42,63 @@ TEST(ModelInfoRoundTrip, MissingDetectedRegionOmittedFromJsonAndParsesEmpty) { EXPECT_TRUE(restored.detected_region.empty()); } +TEST(ModelInfoRoundTrip, VariantMetadataSurvivesRoundTrip) { + ModelInfo original; + original.model_id = "package-model:1"; + original.name = "package-model"; + original.version = 1; + original.alias = "package-model"; + original.uri = "azureml://test/package-model/1"; + + ModelPackageVariant cpu_variant; + cpu_variant.name = "cpu-compiled"; + cpu_variant.execution_provider = "CPUExecutionProvider"; + cpu_variant.device_type = DeviceType::kCPU; + cpu_variant.compatibility_string = "compat-a"; + + ModelPackageVariant gpu_variant; + gpu_variant.name = "gpu-compiled"; + gpu_variant.execution_provider = "CUDAExecutionProvider"; + gpu_variant.device_type = DeviceType::kGPU; + gpu_variant.compatibility_string = "compat-b"; + + ModelPackageMetadata package_metadata; + package_metadata.schema_version = 3; + package_metadata.variants = {cpu_variant, gpu_variant}; + + ModelVariantMetadata variant_metadata; + variant_metadata.model_format = "ort-model-package"; + variant_metadata.model_package = package_metadata; + original.variant_metadata = variant_metadata; + + nlohmann::json j = ModelInfoToJson(original); + ASSERT_TRUE(j.contains("variantMetadata")); + EXPECT_EQ(j["variantMetadata"]["modelFormat"], "ort-model-package"); + EXPECT_EQ(j["variantMetadata"]["modelPackage"]["schemaVersion"], 3); + EXPECT_EQ(j["variantMetadata"]["modelPackage"]["variants"][0]["device"], "cpu"); + EXPECT_EQ(j["variantMetadata"]["modelPackage"]["variants"][1]["device"], "gpu"); + + ModelInfo restored = ModelInfoFromJson(j); + ASSERT_TRUE(restored.variant_metadata.has_value()); + ASSERT_TRUE(restored.variant_metadata->model_format.has_value()); + EXPECT_EQ(*restored.variant_metadata->model_format, "ort-model-package"); + + ASSERT_TRUE(restored.variant_metadata->model_package.has_value()); + ASSERT_TRUE(restored.variant_metadata->model_package->schema_version.has_value()); + EXPECT_EQ(*restored.variant_metadata->model_package->schema_version, 3); + + const auto& variants = restored.variant_metadata->model_package->variants; + ASSERT_EQ(variants.size(), 2u); + EXPECT_EQ(variants[0].name, "cpu-compiled"); + EXPECT_EQ(variants[0].execution_provider, "CPUExecutionProvider"); + EXPECT_EQ(variants[0].device_type, DeviceType::kCPU); + EXPECT_EQ(variants[0].compatibility_string, "compat-a"); + EXPECT_EQ(variants[1].name, "gpu-compiled"); + EXPECT_EQ(variants[1].execution_provider, "CUDAExecutionProvider"); + EXPECT_EQ(variants[1].device_type, DeviceType::kGPU); + EXPECT_EQ(variants[1].compatibility_string, "compat-b"); +} + TEST(ModelInfoRoundTrip, ReasoningFieldsSurviveRoundTrip) { ModelInfo original; original.model_id = "test-model:1"; 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..b8d3a017e 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 @@ -70,6 +70,50 @@ class TempModelDir { std::string path_; }; +class TempModelPackage { + public: + TempModelPackage(const std::string& model_name, + const std::string& first_config, + const std::string& second_config = {}) { + path_ = (std::filesystem::temp_directory_path() / ("fl_package_test_" + model_name)).string(); + std::filesystem::create_directories(std::filesystem::path(path_) / "variant-a"); + + std::ofstream manifest(std::filesystem::path(path_) / "manifest.json"); + manifest << R"({ + "schema_version": "1.0", + "components": { + "model": { + "variants": { + "variant-a": { "ep": "CPUExecutionProvider" })"; + if (!second_config.empty()) { + manifest << R"(, + "variant-b": { "ep": "CPUExecutionProvider" })"; + std::filesystem::create_directories(std::filesystem::path(path_) / "variant-b"); + std::ofstream(std::filesystem::path(path_) / "variant-b" / "genai_config.json") << second_config; + } + manifest << R"( + } + } + } + })"; + + std::ofstream(std::filesystem::path(path_) / "variant-a" / "genai_config.json") << first_config; + } + + ~TempModelPackage() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + TempModelPackage(const TempModelPackage&) = delete; + TempModelPackage& operator=(const TempModelPackage&) = delete; + + const std::string& path() const { return path_; } + + private: + std::string path_; +}; + } // namespace // --------------------------------------------------------------------------- @@ -179,6 +223,36 @@ TEST(ModelLoadManagerTest, LoadGenericGpuModel_CudaAvailable_AutoSelectsCuda) { EXPECT_EQ(ep.prepared_ep, "CUDAExecutionProvider"); } +TEST(ModelLoadManagerTest, ModelPackageDoesNotRequireRootGenaiConfig) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + TempModelPackage package("package-no-root-config", + R"({"model":{"type":"phi3","context_length":128},"search":{"max_length":128}})"); + + try { + mgr.LoadModel(package.path(), "package-no-root-config"); + } catch (const fl::Exception& e) { + EXPECT_EQ(std::string(e.what()).find("does not contain genai_config.json"), std::string::npos); + } +} + +TEST(ModelLoadManagerTest, ModelPackageAcceptsDivergentRuntimeMetadataConservatively) { + CpuOnlyDetector ep; + fl::StderrLogger logger; + fl::ModelLoadManager mgr(ep, logger); + TempModelPackage package( + "package-divergent-config", + R"({"model":{"type":"phi3","context_length":128},"search":{"max_length":128}})", + R"({"model":{"type":"phi3","context_length":256},"search":{"max_length":256}})"); + + try { + mgr.LoadModel(package.path(), "package-divergent-config"); + } catch (const fl::Exception& e) { + EXPECT_EQ(std::string(e.what()).find("runtime metadata"), std::string::npos); + } +} + // --------------------------------------------------------------------------- // Unload-with-live-sessions tests (real model load) // ---------------------------------------------------------------------------