From a8717dbcd74d2674893774caf19197c8b86da638 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Thu, 9 Jul 2026 13:06:20 -0700 Subject: [PATCH 1/6] impl --- .../cpp/src/catalog/azure_catalog_client.cc | 210 ++++----- sdk_v2/cpp/src/catalog/azure_catalog_client.h | 18 +- .../cpp/src/catalog/azure_catalog_models.cc | 244 ++++++++-- sdk_v2/cpp/src/catalog/azure_catalog_models.h | 46 +- sdk_v2/cpp/src/catalog/azure_model_catalog.h | 4 +- sdk_v2/cpp/src/catalog/catalog_client.h | 2 +- .../test/internal_api/azure_catalog_test.cc | 427 ++++++------------ 7 files changed, 469 insertions(+), 482 deletions(-) diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_client.cc b/sdk_v2/cpp/src/catalog/azure_catalog_client.cc index af7a7acf5..f3e301e1c 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_client.cc +++ b/sdk_v2/cpp/src/catalog/azure_catalog_client.cc @@ -21,11 +21,9 @@ namespace fl { namespace { -constexpr const char* kEntitiesPath = "/entities/crossRegion"; constexpr int kPageSize = 50; -// Region detection probe. -constexpr const char* kRegionProbeUrl = "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"; +constexpr const char* kAssetGalleryModelsUrl = "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"; constexpr const char* kRegionProbeBody = R"({"filters":[],"pageSize":1})"; constexpr const char* kServedByClusterHeader = "azureml-served-by-cluster"; constexpr const char* kDefaultRegion = "centralus"; @@ -45,12 +43,12 @@ std::string TrimSingleQuotes(const std::string& s) { } /// Build the values for the foundryLocal tag filter from the override string. -/// Empty override → {""} (public models). Otherwise split on ',', drop entries -/// that are empty after whitespace-trimming, then strip surrounding quotes so a -/// caller can request an explicit empty value via "''". +/// Empty override → {} so the caller can apply the default deployment option. +/// Otherwise split on ',', drop entries that are empty after whitespace-trimming, +/// then strip surrounding quotes. std::vector CreateModelFilter(const std::string& filter_override) { if (filter_override.empty()) { - return {std::string{}}; + return {}; } std::vector values; @@ -94,35 +92,14 @@ std::string ExtractRegionFromClusterHeader(const std::string& header_value) { return {}; } -/// Split a catalog URL of the form `https://{host}/api/{region}/{suffix}` into -/// its prefix ("https://{host}/api/") and suffix ("/{suffix}"). Returns false if -/// the URL doesn't match that shape, in which case it must be used verbatim. -bool TryParseRegionalCatalogUrl(const std::string& url, std::string* prefix, std::string* suffix) { - static const std::string kApiMarker = "/api/"; - const auto api_pos = url.find(kApiMarker); - if (api_pos == std::string::npos) { - return false; - } - - const auto region_start = api_pos + kApiMarker.size(); - const auto region_end = url.find('/', region_start); - if (region_end == std::string::npos || region_end == region_start) { - return false; - } - - *prefix = url.substr(0, region_start); - *suffix = url.substr(region_end); - return true; -} - -bool UsesRegionalRouting(bool regional_template, const std::string& region) { - return regional_template && !region.empty(); +bool IsAssetGalleryUrl(const std::string& url) { + return url.find("/asset-gallery/") != std::string::npos; } /// Detect the Azure region by POSTing a probe to the catalog gallery and reading /// the `azureml-served-by-cluster` response header. Returns "centralus" on failure. std::string DetectRegion(const AzureCatalogClient::HttpPostResponseFn& http_post_response, ILogger& logger) { - http::HttpResponse response = http_post_response(kRegionProbeUrl, kRegionProbeBody); + http::HttpResponse response = http_post_response(kAssetGalleryModelsUrl, kRegionProbeBody); std::string region = kDefaultRegion; if (response.status >= 200 && response.status < 300) { @@ -143,31 +120,22 @@ std::string DetectRegion(const AzureCatalogClient::HttpPostResponseFn& http_post return region; } -std::string BuildRegionalUrl(const std::string& url_prefix, const std::string& url_suffix, const std::string& region) { - return url_prefix + region + url_suffix + kEntitiesPath; -} - -std::string BuildRequestUrl(const std::string& base_url, - bool regional, - const std::string& region, - const std::string& url_prefix, - const std::string& url_suffix) { - if (regional) { - return BuildRegionalUrl(url_prefix, url_suffix, region); +std::string BuildRequestUrl(const std::string& base_url) { + if (base_url.empty()) { + return kAssetGalleryModelsUrl; } - return base_url + kEntitiesPath; + return base_url; } std::string BuildRequestBody(const std::vector& filters, const std::optional& skip, const std::optional& continuation_token) { AzureCatalogRequest request; - request.resource_ids.push_back({"azureml", "Registry"}); - request.index_entities_request.filters = filters; - request.index_entities_request.page_size = kPageSize; - request.index_entities_request.skip = skip; - request.index_entities_request.continuation_token = continuation_token; + request.filters = filters; + request.page_size = kPageSize; + request.skip = skip; + request.continuation_token = continuation_token; const nlohmann::json body = request; return body.dump(); @@ -197,40 +165,69 @@ std::vector> BuildSearchFilters( bool latest_only = true, const std::string& model_alias = "", const std::string& model_name = "") { - std::vector> filter_sets; - for (const auto& [device, eps] : ep_detector.GetAvailableDevicesToEPs()) { - std::vector filters; - filters.push_back(MakeFilter("type", {"models"})); - filters.push_back(MakeFilter("kind", {"Versioned"})); - if (latest_only) { - filters.push_back(MakeFilter("labels", {"latest"})); - } - filters.push_back(MakeFilter("annotations/tags/foundryLocal", model_filter)); - if (!model_alias.empty()) { - filters.push_back(MakeFilter("annotations/tags/alias", {model_alias})); - } - if (!model_name.empty()) { - filters.push_back(MakeFilter("properties/name", {model_name})); - } - filters.push_back(MakeFilter("properties/variantInfo/variantMetadata/device", {ToLower(device)})); - filters.push_back(MakeFilter("properties/variantInfo/variantMetadata/executionProvider", eps)); - filter_sets.push_back(std::move(filters)); - } + // Full parameter-driven filter sets (keep for easy rollback once catalog models are updated): + // std::vector> filter_sets; + // for (const auto& [device, eps] : ep_detector.GetAvailableDevicesToEPs()) { + // std::vector filters; + // + // std::vector deployment_options = model_filter; + // if (deployment_options.empty()) { + // deployment_options.push_back("foundryLocalDevices"); + // } + // + // filters.push_back(MakeFilter("DeploymentOptions", std::move(deployment_options))); + // if (!model_alias.empty()) { + // filters.push_back(MakeFilter("Alias", {model_alias})); + // } + // if (!model_name.empty()) { + // filters.push_back(MakeFilter("Name", {model_name})); + // } + // filters.push_back(MakeFilter("VariantInformation/VariantMetadata/Device", {ToLower(device)})); + // filters.push_back(MakeFilter("VariantInformation/VariantMetadata/ExecutionProvider", eps)); + // + // if (!latest_only) { + // // Placeholder to keep the parameter part of the behavior contract. + // // Asset-gallery query currently does not require an extra field to fetch all versions. + // } + // + // filter_sets.push_back(std::move(filters)); + // } + // return filter_sets; + + // Temporary CPU-only path for catalog validation. + (void)ep_detector; + (void)model_filter; + (void)latest_only; + (void)model_alias; + (void)model_name; + + std::vector filters; + filters.push_back(MakeFilter("VariantInformation/VariantMetadata/Device", {"cpu"})); + std::vector> filter_sets; + filter_sets.push_back(std::move(filters)); return filter_sets; } std::vector BuildModelIdFilters(const std::vector& model_filter, const std::vector& model_ids) { - // Looking up specific IDs: no labels=latest (we want exact versions) and no - // device/EP filters (the IDs already pin the variant). std::vector filters; - filters.push_back(MakeFilter("type", {"models"})); - filters.push_back(MakeFilter("kind", {"Versioned"})); - filters.push_back(MakeFilter("annotations/tags/foundryLocal", model_filter)); - filters.push_back(MakeFilter("properties/id", model_ids)); + std::vector deployment_options = model_filter; + if (deployment_options.empty()) { + deployment_options.push_back("foundryLocalDevices"); + } + + std::vector names; + names.reserve(model_ids.size()); + for (const auto& model_id : model_ids) { + const auto colon = model_id.rfind(':'); + names.push_back(colon == std::string::npos ? model_id : model_id.substr(0, colon)); + } + + filters.push_back(MakeFilter("DeploymentOptions", deployment_options)); + filters.push_back(MakeFilter("Name", names)); return filters; } @@ -262,91 +259,46 @@ AzureCatalogClient::AzureCatalogClient(const std::string& base_url, base_url_.pop_back(); } - regional_template_ = TryParseRegionalCatalogUrl(base_url_, &url_prefix_, &url_suffix_); - - // An explicit region is a hard override. Empty/"auto" means detect the region, - // but only for Azure URLs that can be rewritten per region. + // An explicit region is a hard override. Empty/"auto" means detect the region + // when using the asset-gallery catalog service. const auto normalized_catalog_region = ToLower(catalog_region); if (!normalized_catalog_region.empty() && normalized_catalog_region != "auto") { region_ = normalized_catalog_region; - } else if (regional_template_) { + } else if (base_url_.empty() || IsAssetGalleryUrl(base_url_)) { region_ = DetectRegion(http_post_response_, logger_); } } std::optional AzureCatalogClient::FetchFilterSet( const std::vector& filters) { - const bool regional = UsesRegionalRouting(regional_template_, region_); - FetchedFilterSet result; result.region = region_; std::optional skip; std::optional continuation_token; - std::optional pinned_region; // region that served page 1 (regional mode) + const std::string request_url = BuildRequestUrl(base_url_); while (true) { const std::string body = BuildRequestBody(filters, skip, continuation_token); - http::HttpResponse response; - if (regional && !pinned_region) { - // Page 1: run through region fallback starting from the sticky region (last known-good) or the active region. - // Exhaustion means every candidate had a retryable region-health failure, so fail just this filter set. - const std::string start = region_fallback_.StickyRegion().value_or(region_); - try { - auto fallback_result = region_fallback_.Execute(start, [&](const std::string& r) { - return http_post_response_(BuildRegionalUrl(url_prefix_, url_suffix_, r), body); - }); - response = std::move(fallback_result.response); - pinned_region = fallback_result.region; - result.region = fallback_result.region; - region_ = fallback_result.region; // active region biases later filter sets - } catch (const std::exception& ex) { - logger_.Log(LogLevel::Warning, - std::string("catalog: filter set failed across all regions: ") + ex.what()); - return std::nullopt; - } - } else if (regional) { - // Subsequent pages are pinned to the region that served page 1 — a filter set - // never mixes regions (continuation tokens are region-specific). - response = http_post_response_(BuildRegionalUrl(url_prefix_, url_suffix_, *pinned_region), body); - } else { - // Non-regional / custom URL: single verbatim attempt, no fallback. - response = http_post_response_(BuildRequestUrl(base_url_, regional, region_, url_prefix_, url_suffix_), body); - } + http::HttpResponse response = http_post_response_(request_url, body); if (response.status == 0 || response.status < 200 || response.status >= 300) { - if (regional && IsRegionRetryableStatus(response.status)) { - // Region-health failure (including mid-pagination on the pinned region): fail this filter set only. Because - // models are committed atomically below, a later page failure cannot leak a partial filter-set result. - logger_.Log(LogLevel::Warning, - "catalog: filter set failed (" + http::DescribeFailure(response) + "); skipping this filter set."); - return std::nullopt; - } - - const std::string url = regional && pinned_region - ? BuildRegionalUrl(url_prefix_, url_suffix_, *pinned_region) - : BuildRequestUrl(base_url_, regional, region_, url_prefix_, url_suffix_); FL_THROW(FOUNDRY_LOCAL_ERROR_NETWORK, - "catalog request to " + url + " failed: " + http::DescribeFailure(response)); + "catalog request to " + request_url + " failed: " + http::DescribeFailure(response)); } const auto parsed = nlohmann::json::parse(response.body).get(); - if (!parsed.index_entities_response) { - break; - } - - const auto& page = *parsed.index_entities_response; - if (page.models.empty()) { + if (parsed.models.empty()) { break; } - result.models.insert(result.models.end(), page.models.begin(), page.models.end()); + result.models.insert(result.models.end(), parsed.models.begin(), parsed.models.end()); // Advance pagination. A non-positive nextSkip and an empty token mean "done". - skip = (page.next_skip && *page.next_skip > 0) ? page.next_skip : std::nullopt; - continuation_token = (page.continuation_token && !page.continuation_token->empty()) - ? page.continuation_token + skip = (parsed.next_skip && *parsed.next_skip > 0) ? parsed.next_skip : std::nullopt; + continuation_token = (parsed.continuation_token && !parsed.continuation_token->empty()) + ? parsed.continuation_token : std::nullopt; if (!skip && !continuation_token) { diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_client.h b/sdk_v2/cpp/src/catalog/azure_catalog_client.h index c87c650b8..5f80f6b3a 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_client.h +++ b/sdk_v2/cpp/src/catalog/azure_catalog_client.h @@ -18,19 +18,20 @@ namespace fl { /// Live Azure Foundry catalog client. Queries the catalog REST API -/// (`{base_url}/entities/crossRegion`) for the models available to the local +/// (`asset-gallery/v1.0/models`) for the models available to the local /// hardware, paginating through results and converting each entry to ModelInfo. /// /// Uses one filter set per detected device, page size 50, and pagination via -/// skip + continuationToken. +/// skip + continuationToken. The detected region is stamped onto results so +/// downloads can target the matching regional model registry. class AzureCatalogClient : public ICatalogClient { public: /// Response-aware HTTP POST. Used for region detection, catalog fetches, and regional fallback. using HttpPostResponseFn = std::function; - /// @param base_url Catalog base URL, e.g. "https://ai.azure.com/api/centralus/ux/v1.0". - /// @param filter_override Foundry Local tag filter. "" means public models; "''" means a single empty value. + /// @param base_url Catalog endpoint. Empty means the default V2 asset-gallery endpoint. + /// @param filter_override Deployment-option override. Empty means `foundryLocalDevices`. /// @param ep_detector Reports available device and execution-provider pairs. /// @param logger Logger. /// @param http_post HTTP POST implementation. The default uses `http::HttpPostWithResponse`. @@ -76,19 +77,14 @@ class AzureCatalogClient : public ICatalogClient { std::vector FetchAllFilterSets(); std::string base_url_; - std::vector model_filter_; // foundryLocal tag values + std::vector model_filter_; // deploymentOptions filter values const IEpDetector& ep_detector_; ILogger& logger_; HttpPostResponseFn http_post_response_; RegionFallback region_fallback_; - // Region state. region_ is the active region (empty = use base_url verbatim). - // When base_url matches the https://{host}/api/{region}/{suffix} template, - // url_prefix_/url_suffix_ let us synthesize per-region URLs. + // Region state used for model-registry routing after catalog discovery. std::string region_; - std::string url_prefix_; - std::string url_suffix_; - bool regional_template_ = false; }; } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_models.cc b/sdk_v2/cpp/src/catalog/azure_catalog_models.cc index 47f806ce7..0029bff8d 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_models.cc +++ b/sdk_v2/cpp/src/catalog/azure_catalog_models.cc @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include @@ -57,6 +59,42 @@ int64_t ParseIso8601ToUnix(const std::string& iso_str) { return t == static_cast(-1) ? 0 : static_cast(t); } +std::optional ParseIntString(const std::optional& value) { + if (!value || value->empty()) { + return std::nullopt; + } + + try { + return std::stoi(*value); + } catch (...) { + return std::nullopt; + } +} + +std::string JoinStrings(const std::vector& values) { + if (values.empty()) { + return {}; + } + + std::ostringstream joined; + for (std::size_t i = 0; i < values.size(); ++i) { + if (i > 0) { + joined << ','; + } + + joined << values[i]; + } + + return joined.str(); +} + +bool ContainsStringIgnoreCase(const std::vector& values, const std::string& target) { + const auto lowered_target = ToLower(target); + return std::any_of(values.begin(), values.end(), [&](const std::string& value) { + return ToLower(value) == lowered_target; + }); +} + DeviceType ParseDeviceType(const std::string& device) { const auto lower = ToLower(device); if (lower == "cpu") { @@ -87,14 +125,7 @@ void to_json(nlohmann::json& j, const CatalogFilter& f) { }; } -void to_json(nlohmann::json& j, const CatalogResource& r) { - j = nlohmann::json{ - {"resourceId", r.resource_id}, - {"entityContainerType", r.entity_container_type}, - }; -} - -void to_json(nlohmann::json& j, const IndexEntitiesRequest& r) { +void to_json(nlohmann::json& j, const AzureCatalogRequest& r) { j = nlohmann::json{ {"filters", r.filters}, {"pageSize", r.page_size}, @@ -109,22 +140,38 @@ void to_json(nlohmann::json& j, const IndexEntitiesRequest& r) { } } -void to_json(nlohmann::json& j, const AzureCatalogRequest& r) { - j = nlohmann::json{ - {"resourceIds", r.resource_ids}, - {"indexEntitiesRequest", r.index_entities_request}, - }; -} - // ======================================================================== // Response deserialization (from_json) // ======================================================================== +void from_json(const nlohmann::json& j, TextLimits& t) { + opt_int64(j, "inputContextWindow", t.input_context_window); + opt_int64(j, "maxOutputTokens", t.max_output_tokens); +} + +void from_json(const nlohmann::json& j, ModelLimits& m) { + if (j.contains("textLimits") && j["textLimits"].is_object()) { + m.text_limits = j["textLimits"].get(); + } + + if (j.contains("supportedInputModalities") && j["supportedInputModalities"].is_array()) { + m.supported_input_modalities = j["supportedInputModalities"].get>(); + } + + if (j.contains("supportedOutputModalities") && j["supportedOutputModalities"].is_array()) { + m.supported_output_modalities = j["supportedOutputModalities"].get>(); + } +} + void from_json(const nlohmann::json& j, VariantMetadata& v) { opt_str(j, "modelType", v.model_type); + if (j.contains("quantization") && j["quantization"].is_array()) { + v.quantization = j["quantization"].get>(); + } opt_str(j, "device", v.device); opt_str(j, "executionProvider", v.execution_provider); opt_int64(j, "fileSizeBytes", v.file_size_bytes); + opt_int64(j, "vRamFootprintBytes", v.vram_footprint_bytes); } void from_json(const nlohmann::json& j, VariantParent& v) { @@ -195,6 +242,36 @@ void from_json(const nlohmann::json& j, CatalogAnnotations& a) { void from_json(const nlohmann::json& j, CatalogLocalModel& m) { opt_str(j, "assetId", m.asset_id); opt_str(j, "entityId", m.entity_id); + opt_str(j, "name", m.name); + opt_str(j, "displayName", m.display_name); + opt_str(j, "version", m.version); + opt_str(j, "publisher", m.publisher); + opt_str(j, "license", m.license); + opt_str(j, "licenseDescription", m.license_description); + opt_str(j, "alias", m.alias); + opt_str(j, "minFLVersion", m.min_fl_version); + opt_str(j, "createdTime", m.created_time); + opt_str(j, "author", m.author); + + if (j.contains("inferenceTasks") && j["inferenceTasks"].is_array()) { + m.inference_tasks = j["inferenceTasks"].get>(); + } + + if (j.contains("modelCapabilities") && j["modelCapabilities"].is_array()) { + m.model_capabilities = j["modelCapabilities"].get>(); + } + + if (j.contains("deploymentOptions") && j["deploymentOptions"].is_array()) { + m.deployment_options = j["deploymentOptions"].get>(); + } + + if (j.contains("modelLimits") && j["modelLimits"].is_object()) { + m.model_limits = j["modelLimits"].get(); + } + + if (j.contains("variantInformation") && j["variantInformation"].is_object()) { + m.variant_information = j["variantInformation"].get(); + } if (j.contains("annotations") && j["annotations"].is_object()) { m.annotations = j["annotations"].get(); @@ -210,7 +287,9 @@ void from_json(const nlohmann::json& j, IndexEntitiesResponse& r) { opt_int(j, "nextSkip", r.next_skip); opt_str(j, "continuationToken", r.continuation_token); - if (j.contains("value") && j["value"].is_array()) { + if (j.contains("summaries") && j["summaries"].is_array()) { + r.models = j["summaries"].get>(); + } else if (j.contains("value") && j["value"].is_array()) { r.models = j["value"].get>(); } } @@ -218,6 +297,21 @@ void from_json(const nlohmann::json& j, IndexEntitiesResponse& r) { void from_json(const nlohmann::json& j, AzureCatalogResponse& r) { if (j.contains("indexEntitiesResponse") && j["indexEntitiesResponse"].is_object()) { r.index_entities_response = j["indexEntitiesResponse"].get(); + r.total_count = r.index_entities_response->total_count; + r.models = r.index_entities_response->models; + r.next_skip = r.index_entities_response->next_skip; + r.continuation_token = r.index_entities_response->continuation_token; + return; + } + + opt_int(j, "totalCount", r.total_count); + opt_int(j, "nextSkip", r.next_skip); + opt_str(j, "continuationToken", r.continuation_token); + + if (j.contains("summaries") && j["summaries"].is_array()) { + r.models = j["summaries"].get>(); + } else if (j.contains("value") && j["value"].is_array()) { + r.models = j["value"].get>(); } } @@ -234,36 +328,41 @@ void from_json(const nlohmann::json& j, CatalogPromptTemplate& t) { // ======================================================================== std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { - // Required fields — skip entry if missing. if (!cm.asset_id || cm.asset_id->empty()) { return std::nullopt; } - if (!cm.properties || !cm.properties->name || cm.properties->name->empty()) { + const std::string model_name = cm.name.value_or( + (cm.properties && cm.properties->name) ? *cm.properties->name : std::string{}); + if (model_name.empty()) { return std::nullopt; } - if (!cm.entity_id || cm.entity_id->empty()) { - return std::nullopt; + const VariantInfo* variant_info = nullptr; + if (cm.variant_information) { + variant_info = &*cm.variant_information; + } else if (cm.properties && cm.properties->variant_info) { + variant_info = &*cm.properties->variant_info; } - if (!cm.properties->variant_info) { + if (!variant_info) { return std::nullopt; } - const auto& props = *cm.properties; - int version = static_cast(props.version.value_or(0)); + const auto version = ParseIntString(cm.version).value_or( + cm.properties ? static_cast(cm.properties->version.value_or(0)) : 0); // Extract parent model URI (used for alias and stored as a property). std::string parent_uri; - if (props.variant_info && !props.variant_info->parents.empty() && - props.variant_info->parents[0].asset_id) { - parent_uri = *props.variant_info->parents[0].asset_id; + if (!variant_info->parents.empty() && variant_info->parents[0].asset_id) { + parent_uri = *variant_info->parents[0].asset_id; } - // Determine alias — prefer tags.alias, then short name from parent, then model name. + // Determine alias — prefer the V2 field, then legacy tag, then short name from parent, then model name. std::string alias; - if (cm.annotations && cm.annotations->tags && cm.annotations->tags->alias) { + if (cm.alias && !cm.alias->empty()) { + alias = *cm.alias; + } else if (cm.annotations && cm.annotations->tags && cm.annotations->tags->alias) { alias = *cm.annotations->tags->alias; } else { if (!parent_uri.empty()) { @@ -271,20 +370,20 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { } if (alias.empty()) { - alias = *props.name; + alias = model_name; } } ModelInfo info; - info.model_id = *props.name + ":" + std::to_string(version); - info.name = *props.name; + info.model_id = model_name + ":" + std::to_string(version); + info.name = model_name; info.version = version; info.alias = alias; info.uri = *cm.asset_id; // Device type, execution provider, and model type from variant metadata - if (props.variant_info && props.variant_info->variant_metadata) { - const auto& vm = *props.variant_info->variant_metadata; + if (variant_info->variant_metadata) { + const auto& vm = *variant_info->variant_metadata; if (vm.device) { info.device_type = ParseDeviceType(*vm.device); } @@ -296,6 +395,14 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { // ModelType — defaults to "ONNX" (matches C# ToAzureFoundryLocalModel) info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR] = vm.model_type.value_or("ONNX"); + + if (!vm.quantization.empty()) { + info.model_settings.Add("quantization", JoinStrings(vm.quantization)); + } + + if (vm.vram_footprint_bytes) { + info.model_settings.Add("vramFootprintBytes", std::to_string(*vm.vram_footprint_bytes)); + } } else { info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MODEL_TYPE_STR] = "ONNX"; } @@ -380,6 +487,51 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { } } + if (!cm.inference_tasks.empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_TASK_STR] = cm.inference_tasks.front(); + info.task = cm.inference_tasks.front(); + } + + if (cm.license && !cm.license->empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_LICENSE_STR] = *cm.license; + } + + if (cm.license_description && !cm.license_description->empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_LICENSE_DESCRIPTION_STR] = *cm.license_description; + } + + if (!cm.model_capabilities.empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_CAPABILITIES_STR] = JoinStrings(cm.model_capabilities); + info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_TOOL_CALLING_INT] = + ContainsStringIgnoreCase(cm.model_capabilities, "tool-calling") ? 1 : 0; + info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_REASONING_INT] = + ContainsStringIgnoreCase(cm.model_capabilities, "reasoning") ? 1 : 0; + } + + if (cm.model_limits) { + if (!cm.model_limits->supported_input_modalities.empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR] = + JoinStrings(cm.model_limits->supported_input_modalities); + } + + if (!cm.model_limits->supported_output_modalities.empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR] = + JoinStrings(cm.model_limits->supported_output_modalities); + } + + if (cm.model_limits->text_limits) { + if (cm.model_limits->text_limits->input_context_window) { + info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT] = + *cm.model_limits->text_limits->input_context_window; + } + + if (cm.model_limits->text_limits->max_output_tokens) { + info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT] = + *cm.model_limits->text_limits->max_output_tokens; + } + } + } + if (cm.annotations && cm.annotations->system_catalog_data) { const auto& scd = *cm.annotations->system_catalog_data; if (scd.publisher) { @@ -395,6 +547,14 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { } } + if (cm.publisher && !cm.publisher->empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR] = *cm.publisher; + } + + if (cm.display_name && !cm.display_name->empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_DISPLAY_NAME_STR] = *cm.display_name; + } + // Fallback: tags.maxOutputTokens (string form) if systemCatalogData didn't supply one. if (info.int_properties.find(FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT) == info.int_properties.end() && cm.annotations && cm.annotations->tags && cm.annotations->tags->max_output_tokens) { @@ -406,14 +566,15 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { } } - if (props.min_fl_version) { - info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR] = *props.min_fl_version; + if (cm.min_fl_version && !cm.min_fl_version->empty()) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR] = *cm.min_fl_version; + } else if (cm.properties && cm.properties->min_fl_version) { + info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR] = *cm.properties->min_fl_version; } // File size in MB - if (props.variant_info && props.variant_info->variant_metadata && - props.variant_info->variant_metadata->file_size_bytes) { - int64_t bytes = *props.variant_info->variant_metadata->file_size_bytes; + if (variant_info->variant_metadata && variant_info->variant_metadata->file_size_bytes) { + int64_t bytes = *variant_info->variant_metadata->file_size_bytes; info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT] = bytes / (1024 * 1024); } @@ -425,8 +586,11 @@ std::optional CatalogModelToModelInfo(const CatalogLocalModel& cm) { } // CreatedAtUnix — Properties.CreationContext.CreatedTime → Unix timestamp - if (props.creation_context && props.creation_context->created_time) { - int64_t unix_ts = ParseIso8601ToUnix(*props.creation_context->created_time); + if (cm.created_time && !cm.created_time->empty()) { + int64_t unix_ts = ParseIso8601ToUnix(*cm.created_time); + info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT] = unix_ts; + } else if (cm.properties && cm.properties->creation_context && cm.properties->creation_context->created_time) { + int64_t unix_ts = ParseIso8601ToUnix(*cm.properties->creation_context->created_time); info.int_properties[FOUNDRY_LOCAL_MODEL_PROP_CREATED_AT_UNIX_INT] = unix_ts; } diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_models.h b/sdk_v2/cpp/src/catalog/azure_catalog_models.h index 76cbee132..c91833734 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_models.h +++ b/sdk_v2/cpp/src/catalog/azure_catalog_models.h @@ -28,31 +28,34 @@ struct CatalogFilter { std::vector values; }; -struct CatalogResource { - std::string resource_id; - std::string entity_container_type; -}; - -struct IndexEntitiesRequest { +struct AzureCatalogRequest { std::vector filters; int page_size = 50; std::optional skip; std::optional continuation_token; }; -struct AzureCatalogRequest { - std::vector resource_ids; - IndexEntitiesRequest index_entities_request; +// --- Response types --- + +struct TextLimits { + std::optional input_context_window; + std::optional max_output_tokens; }; -// --- Response types --- +struct ModelLimits { + std::optional text_limits; + std::vector supported_input_modalities; + std::vector supported_output_modalities; +}; /// Variant metadata nested inside Properties → VariantInfo. struct VariantMetadata { std::optional model_type; + std::vector quantization; std::optional device; std::optional execution_provider; std::optional file_size_bytes; + std::optional vram_footprint_bytes; }; struct VariantParent { @@ -109,6 +112,21 @@ struct CatalogAnnotations { struct CatalogLocalModel { std::optional asset_id; std::optional entity_id; + std::optional name; + std::optional display_name; + std::optional version; + std::optional publisher; + std::optional license; + std::optional license_description; + std::optional alias; + std::optional min_fl_version; + std::optional created_time; + std::optional author; + std::vector inference_tasks; + std::vector model_capabilities; + std::vector deployment_options; + std::optional model_limits; + std::optional variant_information; std::optional annotations; std::optional properties; }; @@ -121,6 +139,10 @@ struct IndexEntitiesResponse { }; struct AzureCatalogResponse { + std::optional total_count; + std::vector models; + std::optional next_skip; + std::optional continuation_token; std::optional index_entities_response; }; @@ -142,12 +164,12 @@ struct CatalogPromptTemplate { // --- Request serialization (to_json) --- void to_json(nlohmann::json& j, const CatalogFilter& f); -void to_json(nlohmann::json& j, const CatalogResource& r); -void to_json(nlohmann::json& j, const IndexEntitiesRequest& r); void to_json(nlohmann::json& j, const AzureCatalogRequest& r); // --- Response deserialization (from_json) --- +void from_json(const nlohmann::json& j, TextLimits& t); +void from_json(const nlohmann::json& j, ModelLimits& m); 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.h b/sdk_v2/cpp/src/catalog/azure_model_catalog.h index 5769a3ef7..ef1438450 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.h @@ -38,8 +38,8 @@ class AzureModelCatalog : public BaseModelCatalog { std::vector FetchModelsByIds(const std::vector& model_ids) const override; private: - static constexpr const char* kDefaultCatalogUrl = "https://ai.azure.com/api/centralus/ux/v1.0"; - static constexpr const char* kDefaultCatalogFilter = "''"; + static constexpr const char* kDefaultCatalogUrl = "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"; + static constexpr const char* kDefaultCatalogFilter = "foundryLocalDevices"; std::vector>> catalog_urls_; std::string cache_dir_; diff --git a/sdk_v2/cpp/src/catalog/catalog_client.h b/sdk_v2/cpp/src/catalog/catalog_client.h index e3afcfe78..54fa437fd 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.h +++ b/sdk_v2/cpp/src/catalog/catalog_client.h @@ -58,7 +58,7 @@ std::vector FetchAllModelInfosWithCachedModels( /// Construct a client for the live Azure Foundry catalog. /// - `ep_detector` limits results to models supported by this machine. -/// - `filter_override` sets the foundryLocal tag filter. +/// - `filter_override` overrides the deploymentOptions filter (default `foundryLocalDevices`). /// - `catalog_region` controls regional routing: empty/"auto" means detect it, /// any other value is an explicit region. /// - `disable_region_fallback` disables cross-region retries. 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 96df77366..37953f6ba 100644 --- a/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc +++ b/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc @@ -22,6 +22,8 @@ using namespace fl; namespace { +constexpr const char* kAssetGalleryModelsUrl = "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"; + http::HttpResponse MakeOkResponse(std::string body) { http::HttpResponse response; response.status = 200; @@ -29,6 +31,12 @@ http::HttpResponse MakeOkResponse(std::string body) { return response; } +bool IsRegionProbeRequest(const std::string& body) { + const auto request = nlohmann::json::parse(body); + return request.contains("filters") && request["filters"].is_array() && request["filters"].empty() && + request.value("pageSize", 0) == 1; +} + } // namespace // ======================================================================== @@ -70,86 +78,53 @@ class CpuGpuEpDetector : public IEpDetector { // Request format tests // ======================================================================== -// Verify the generated request JSON has the right structure. -// We compare against the known-good azure_catalog_model_fetch.http request. +// Verify the generated request JSON has the expected per-device structure. TEST(AzureCatalogClientTest, RequestFormatMatchesKnownGood) { AllDevicesEpDetector ep; StderrLogger logger; std::vector captured_urls; std::vector captured_bodies; - // filter_override: "", "test" — matches the .http file's foundryLocal filter values AzureCatalogClient client( - "https://ai.azure.com/api/eastus/ux/v1.0", "''", ep, logger, + kAssetGalleryModelsUrl, "", ep, logger, [&](const std::string& url, const std::string& body) { captured_urls.push_back(url); captured_bodies.push_back(nlohmann::json::parse(body)); - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }, "eastus"); client.FetchAllModels(); - // Our code creates one filter set per device (3 devices → 3 requests). + // One query is issued per detected device. ASSERT_EQ(captured_bodies.size(), 3u); for (const auto& url : captured_urls) { - EXPECT_EQ(url, "https://ai.azure.com/api/eastus/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(url, kAssetGalleryModelsUrl); } // Verify the first request (cpu) matches the expected structure. - // The .http file combines all devices; the client splits requests per device. - // We verify the structure, field names, and operators match. + // The client splits requests per device. const auto& cpu_req = captured_bodies[0]; - // Top-level structure - ASSERT_TRUE(cpu_req.contains("resourceIds")); - ASSERT_TRUE(cpu_req.contains("indexEntitiesRequest")); + ASSERT_TRUE(cpu_req.contains("filters")); + EXPECT_EQ(cpu_req["pageSize"], 50); - // resourceIds - const auto& resources = cpu_req["resourceIds"]; - ASSERT_EQ(resources.size(), 1u); - EXPECT_EQ(resources[0]["resourceId"], "azureml"); - EXPECT_EQ(resources[0]["entityContainerType"], "Registry"); + // Verify filters match deployment options + device + execution provider. + const auto& filters = cpu_req["filters"]; + ASSERT_EQ(filters.size(), 3u); - // indexEntitiesRequest - const auto& idx_req = cpu_req["indexEntitiesRequest"]; - ASSERT_TRUE(idx_req.contains("filters")); - EXPECT_EQ(idx_req["pageSize"], 50); - - // Verify filters match the known-good pattern - const auto& filters = idx_req["filters"]; - ASSERT_EQ(filters.size(), 6u); - - // Filter 0: type=models - EXPECT_EQ(filters[0]["field"], "type"); + EXPECT_EQ(filters[0]["field"], "DeploymentOptions"); EXPECT_EQ(filters[0]["operator"], "eq"); - EXPECT_EQ(filters[0]["values"], nlohmann::json({"models"})); + EXPECT_EQ(filters[0]["values"], nlohmann::json({"foundryLocalDevices"})); - // Filter 1: kind=Versioned - EXPECT_EQ(filters[1]["field"], "kind"); + EXPECT_EQ(filters[1]["field"], "VariantInformation/VariantMetadata/Device"); EXPECT_EQ(filters[1]["operator"], "eq"); - EXPECT_EQ(filters[1]["values"], nlohmann::json({"Versioned"})); + EXPECT_EQ(filters[1]["values"], nlohmann::json({"cpu"})); - // Filter 2: labels=latest - EXPECT_EQ(filters[2]["field"], "labels"); + EXPECT_EQ(filters[2]["field"], "VariantInformation/VariantMetadata/ExecutionProvider"); EXPECT_EQ(filters[2]["operator"], "eq"); - EXPECT_EQ(filters[2]["values"], nlohmann::json({"latest"})); - - // Filter 3: foundryLocal filter — matches "", "test" from the .http file - EXPECT_EQ(filters[3]["field"], "annotations/tags/foundryLocal"); - EXPECT_EQ(filters[3]["operator"], "eq"); - EXPECT_EQ(filters[3]["values"], nlohmann::json({""})); - - // Filter 4: device (per-device split — this is "cpu" for the first request) - EXPECT_EQ(filters[4]["field"], "properties/variantInfo/variantMetadata/device"); - EXPECT_EQ(filters[4]["operator"], "eq"); - EXPECT_EQ(filters[4]["values"], nlohmann::json({"cpu"})); - - // Filter 5: executionProvider - EXPECT_EQ(filters[5]["field"], "properties/variantInfo/variantMetadata/executionProvider"); - EXPECT_EQ(filters[5]["operator"], "eq"); + EXPECT_EQ(filters[2]["values"], nlohmann::json({"CPUExecutionProvider"})); } // Verify page size default is 50. @@ -159,16 +134,15 @@ TEST(AzureCatalogClientTest, DefaultPageSizeIs50) { nlohmann::json captured; AzureCatalogClient client( - "https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, + kAssetGalleryModelsUrl, "", ep, logger, [&](const std::string&, const std::string& body) { captured = nlohmann::json::parse(body); - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }, "eastus"); client.FetchAllModels(); - EXPECT_EQ(captured["indexEntitiesRequest"]["pageSize"], 50); + EXPECT_EQ(captured["pageSize"], 50); } // ======================================================================== @@ -179,49 +153,45 @@ TEST(AzureCatalogClientTest, ParsesModelResponseCorrectly) { CpuOnlyEpDetector ep; StderrLogger logger; - // Realistic single-model response matching the Azure catalog schema + // Realistic single-model response matching the V2 asset-gallery schema. const char* mock_response = R"({ - "indexEntitiesResponse": { - "totalCount": 1, - "value": [ - { - "assetId": "azureml://registries/azureml/models/Phi-4-mini-instruct-generic-cpu/versions/2", - "entityId": "Phi-4-mini-instruct-generic-cpu:2", - "annotations": { - "tags": { - "alias": "Phi-4-mini-instruct", - "foundryLocal": "", - "task": "chat-completion", - "license": "MIT", - "licenseDescription": "MIT License", - "supportsToolCalling": "true", - "promptTemplate": "{\"system\":\"<|system|>\\n{Content}<|end|>\",\"user\":\"<|user|>\\n{Content}<|end|>\",\"assistant\":\"<|assistant|>\\n{Content}<|end|>\",\"prompt\":\"<|user|>\\n{Content}<|end|>\\n<|assistant|>\"}" - }, - "systemCatalogData": { - "publisher": "Microsoft", - "displayName": "Phi-4 Mini Instruct", - "maxOutputTokens": 4096 - } + "totalCount": 1, + "summaries": [ + { + "assetId": "azureml://registries/azureml/models/Phi-4-mini-instruct-generic-cpu/versions/2", + "name": "Phi-4-mini-instruct-generic-cpu", + "displayName": "Phi-4 Mini Instruct", + "version": "2", + "publisher": "Microsoft", + "createdTime": "2026-05-11T22:02:42.9141627+00:00", + "license": "MIT", + "licenseDescription": "MIT License", + "alias": "Phi-4-mini-instruct", + "minFLVersion": "0.3.0", + "inferenceTasks": ["chat-completion"], + "modelCapabilities": ["tool-calling", "reasoning"], + "modelLimits": { + "textLimits": { + "inputContextWindow": 8192, + "maxOutputTokens": 4096 }, - "properties": { - "name": "Phi-4-mini-instruct-generic-cpu", - "version": 2, - "variantInfo": { - "parents": [{"assetId": "azureml://registries/azureml/models/Phi-4-mini-instruct/versions/3"}], - "variantMetadata": { - "modelType": "ONNX", - "device": "cpu", - "executionProvider": "CPUExecutionProvider", - "fileSizeBytes": 4294967296 - } - }, - "minFLVersion": "0.3.0" + "supportedInputModalities": ["text", "image"], + "supportedOutputModalities": ["text"] + }, + "variantInformation": { + "parents": [{"assetId": "azureml://registries/azureml/models/Phi-4-mini-instruct/versions/3"}], + "variantMetadata": { + "modelType": "ONNX", + "quantization": ["RTN"], + "device": "cpu", + "executionProvider": "CPUExecutionProvider", + "fileSizeBytes": 4294967296, + "vRamFootprintBytes": 2147483648 } } - ], - "nextSkip": 0, - "continuationToken": "" - } + } + ], + "continuationToken": "" })"; AzureCatalogClient client("https://test.com", "", ep, logger, @@ -241,23 +211,22 @@ TEST(AzureCatalogClientTest, ParsesModelResponseCorrectly) { EXPECT_EQ(info.device_type, DeviceType::kCPU); EXPECT_EQ(info.execution_provider, "CPUExecutionProvider"); - // Prompt templates should be parsed from the JSON string - ASSERT_FALSE(info.prompt_templates.empty()); - EXPECT_TRUE(info.prompt_templates.count("prompt") > 0); - EXPECT_TRUE(info.prompt_templates.count("system") > 0); - EXPECT_TRUE(info.prompt_templates.count("user") > 0); - EXPECT_TRUE(info.prompt_templates.count("assistant") > 0); - // Metadata properties EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_TASK_STR), "chat-completion"); EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_LICENSE_STR), "MIT"); + EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_LICENSE_DESCRIPTION_STR), "MIT License"); EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR), "Microsoft"); EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_DISPLAY_NAME_STR), "Phi-4 Mini Instruct"); EXPECT_EQ(info.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_TOOL_CALLING_INT), 1); + EXPECT_EQ(info.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_SUPPORTS_REASONING_INT), 1); EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MIN_FL_VERSION_STR), "0.3.0"); EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MODEL_PROVIDER_STR), "AzureFoundry"); EXPECT_EQ(info.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_MAX_OUTPUT_TOKENS_INT), 4096); + EXPECT_EQ(info.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_CONTEXT_LENGTH_INT), 8192); EXPECT_EQ(info.int_properties.at(FOUNDRY_LOCAL_MODEL_PROP_FILESIZE_MB_INT), 4096); // 4GB → 4096 MB + EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_INPUT_MODALITIES_STR), "text,image"); + EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_OUTPUT_MODALITIES_STR), "text"); + EXPECT_EQ(info.string_properties.at(FOUNDRY_LOCAL_MODEL_PROP_CAPABILITIES_STR), "tool-calling,reasoning"); } // Verify that invalid models (missing required fields) are filtered out @@ -265,32 +234,27 @@ TEST(AzureCatalogClientTest, SkipsInvalidModels) { CpuOnlyEpDetector ep; StderrLogger logger; - // Response with one valid model and one missing assetId + // Response with one valid model and one missing assetId. const char* mock_response = R"({ - "indexEntitiesResponse": { - "totalCount": 2, - "value": [ + "totalCount": 2, + "summaries": [ { - "entityId": "no-asset-id", - "properties": { "name": "bad-model", "version": 1, "variantInfo": { "parents": [] } } + "name": "bad-model", + "version": "1", + "variantInformation": { "parents": [] } }, { "assetId": "azureml://registries/azureml/models/good-model/versions/1", - "entityId": "good-model:1", - "annotations": { "tags": { "alias": "good" } }, - "properties": { - "name": "good-model", - "version": 1, - "variantInfo": { + "alias": "good", + "name": "good-model", + "version": "1", + "variantInformation": { "parents": [], "variantMetadata": { "device": "cpu", "executionProvider": "CPUExecutionProvider" } - } } } ], - "nextSkip": 0, "continuationToken": "" - } })"; AzureCatalogClient client("https://test.com", "", ep, logger, @@ -340,8 +304,8 @@ TEST(AzureCatalogClientTest, FollowsPagination) { // Second page — no more // Verify skip/token were passed - EXPECT_EQ(req["indexEntitiesRequest"]["skip"], 1); - EXPECT_EQ(req["indexEntitiesRequest"]["continuationToken"], "token123"); + EXPECT_EQ(req["skip"], 1); + EXPECT_EQ(req["continuationToken"], "token123"); return MakeOkResponse(R"({ "indexEntitiesResponse": { "totalCount": 2, @@ -363,14 +327,14 @@ TEST(AzureCatalogClientTest, FollowsPagination) { } // ======================================================================== -// Live integration test — fetches real models from ai.azure.com +// Live integration test — fetches real models from the asset-gallery endpoint // Disabled by default. Run with: --gtest_also_run_disabled_tests // ======================================================================== TEST(AzureCatalogClientTest, DISABLED_LiveFetchModelsFromAzure) { AllDevicesEpDetector ep; StderrLogger logger; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "''", ep, logger); + AzureCatalogClient client(kAssetGalleryModelsUrl, "''", ep, logger); // Use the real HTTP client (WinHTTP on Windows) auto model_infos = client.FetchAllModelInfos(); @@ -411,8 +375,7 @@ TEST(AzureCatalogClientTest, BuildModelIdFiltersProducesCorrectStructure) { AzureCatalogClient client("https://test.com", "", ep, logger, [&](const std::string&, const std::string& body) { captured = nlohmann::json::parse(body); - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }); client.FetchModelsByIds({"phi-4-mini:3", "llama-3:1"}); @@ -420,29 +383,18 @@ TEST(AzureCatalogClientTest, BuildModelIdFiltersProducesCorrectStructure) { // Should have made exactly one HTTP call (single filter set, no pagination). ASSERT_FALSE(captured.is_null()); - const auto& filters = captured["indexEntitiesRequest"]["filters"]; - ASSERT_EQ(filters.size(), 4u); - - // Filter 0: type=models - EXPECT_EQ(filters[0]["field"], "type"); - EXPECT_EQ(filters[0]["values"], nlohmann::json({"models"})); - - // Filter 1: kind=Versioned - EXPECT_EQ(filters[1]["field"], "kind"); - EXPECT_EQ(filters[1]["values"], nlohmann::json({"Versioned"})); + const auto& filters = captured["filters"]; + ASSERT_EQ(filters.size(), 2u); - // Filter 2: foundryLocal tag (no labels=latest!) - EXPECT_EQ(filters[2]["field"], "annotations/tags/foundryLocal"); + EXPECT_EQ(filters[0]["field"], "DeploymentOptions"); + EXPECT_EQ(filters[0]["values"], nlohmann::json({"foundryLocalDevices"})); - // Filter 3: properties/id with the requested model IDs - EXPECT_EQ(filters[3]["field"], "properties/id"); - EXPECT_EQ(filters[3]["values"], nlohmann::json({"phi-4-mini:3", "llama-3:1"})); + EXPECT_EQ(filters[1]["field"], "Name"); + EXPECT_EQ(filters[1]["values"], nlohmann::json({"phi-4-mini", "llama-3"})); - // Verify NO labels filter and NO device/EP filters exist. for (const auto& f : filters) { - EXPECT_NE(f["field"], "labels"); - EXPECT_NE(f["field"], "properties/variantInfo/variantMetadata/device"); - EXPECT_NE(f["field"], "properties/variantInfo/variantMetadata/executionProvider"); + EXPECT_NE(f["field"], "VariantInformation/VariantMetadata/Device"); + EXPECT_NE(f["field"], "VariantInformation/VariantMetadata/ExecutionProvider"); } } @@ -454,8 +406,7 @@ TEST(AzureCatalogClientTest, FetchModelsByIdsEmptyReturnsEmptyNoHttp) { AzureCatalogClient client("https://test.com", "", ep, logger, [&](const std::string&, const std::string&) { http_called = true; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }); auto result = client.FetchModelsByIds({}); @@ -549,18 +500,17 @@ TEST(AzureCatalogClientTest, WithCachedModels_UnresolvedId_TriggersSecondFetch) } else { // Second fetch — looking up the unresolved model by ID. auto req = nlohmann::json::parse(body); - const auto& filters = req["indexEntitiesRequest"]["filters"]; + const auto& filters = req["filters"]; - // Verify the second call uses properties/id filter. - bool has_id_filter = false; + bool has_name_filter = false; for (const auto& f : filters) { - if (f["field"] == "properties/id") { - has_id_filter = true; - EXPECT_EQ(f["values"], nlohmann::json({"old-model:1"})); + if (f["field"] == "Name") { + has_name_filter = true; + EXPECT_EQ(f["values"], nlohmann::json({"old-model"})); } } - EXPECT_TRUE(has_id_filter); + EXPECT_TRUE(has_name_filter); return MakeOkResponse(MakeMockCatalogResponse({{"old-model", 1}})); } @@ -712,8 +662,8 @@ TEST(AzureCatalogClientTest, ParsesTagsCaseInsensitively) { auto req = nlohmann::json::parse(body); std::string device_filter; - for (const auto& f : req["indexEntitiesRequest"]["filters"]) { - if (f["field"] == "properties/variantInfo/variantMetadata/device") { + for (const auto& f : req["filters"]) { + if (f["field"] == "VariantInformation/VariantMetadata/Device") { device_filter = f["values"][0].get(); break; } @@ -830,78 +780,74 @@ TEST(AzureCatalogClientTest, DetectRegionParsesClusterHeader) { StderrLogger logger; std::string probe_url; std::string catalog_url; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, - [&](const std::string& url, const std::string&) { - if (url == "https://api.catalog.azureml.ms/asset-gallery/v1.0/models") { + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, + [&](const std::string& url, const std::string& body) { + if (IsRegionProbeRequest(body)) { probe_url = url; return MakeProbeResponse(200, "vienna-westus2-01"); } catalog_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }); client.FetchAllModels(); EXPECT_EQ(probe_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); - EXPECT_EQ(catalog_url, "https://ai.azure.com/api/westus2/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(catalog_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); } TEST(AzureCatalogClientTest, DetectRegionMissingHeaderDefaultsToCentralUs) { CpuOnlyEpDetector ep; StderrLogger logger; std::string catalog_url; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, - [&](const std::string& url, const std::string&) { - if (url == "https://api.catalog.azureml.ms/asset-gallery/v1.0/models") { + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, + [&](const std::string& url, const std::string& body) { + if (IsRegionProbeRequest(body)) { return MakeProbeResponse(200, /*cluster_header=*/""); } catalog_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }); client.FetchAllModels(); - EXPECT_EQ(catalog_url, "https://ai.azure.com/api/centralus/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(catalog_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); } TEST(AzureCatalogClientTest, DetectRegionMalformedHeaderDefaultsToCentralUs) { CpuOnlyEpDetector ep; StderrLogger logger; std::string catalog_url; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, - [&](const std::string& url, const std::string&) { - if (url == "https://api.catalog.azureml.ms/asset-gallery/v1.0/models") { + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, + [&](const std::string& url, const std::string& body) { + if (IsRegionProbeRequest(body)) { return MakeProbeResponse(200, "not-a-cluster-name"); } catalog_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }); client.FetchAllModels(); - EXPECT_EQ(catalog_url, "https://ai.azure.com/api/centralus/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(catalog_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); } TEST(AzureCatalogClientTest, DetectRegionProbeFailureDefaultsToCentralUs) { CpuOnlyEpDetector ep; StderrLogger logger; std::string catalog_url; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, - [&](const std::string& url, const std::string&) { - if (url == "https://api.catalog.azureml.ms/asset-gallery/v1.0/models") { + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, + [&](const std::string& url, const std::string& body) { + if (IsRegionProbeRequest(body)) { return MakeProbeResponse(503, "vienna-westus2-01"); } catalog_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }); client.FetchAllModels(); - EXPECT_EQ(catalog_url, "https://ai.azure.com/api/centralus/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(catalog_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); } TEST(AzureCatalogClientTest, ExplicitRegionOverridesDetection) { @@ -909,18 +855,17 @@ TEST(AzureCatalogClientTest, ExplicitRegionOverridesDetection) { StderrLogger logger; bool probe_called = false; std::string catalog_url; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, [&](const std::string& url, const std::string&) { - if (url == "https://api.catalog.azureml.ms/asset-gallery/v1.0/models") { + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, [&](const std::string& url, const std::string& body) { + if (IsRegionProbeRequest(body)) { probe_called = true; } catalog_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); }, "westeurope"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }, "westeurope"); client.FetchAllModels(); EXPECT_FALSE(probe_called); - EXPECT_EQ(catalog_url, "https://ai.azure.com/api/westeurope/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(catalog_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); } // ======================================================================== @@ -931,13 +876,12 @@ TEST(AzureCatalogClientTest, ActiveRegionDrivesCatalogUrl) { CpuOnlyEpDetector ep; StderrLogger logger; std::string captured_url; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, [&](const std::string& url, const std::string&) { + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, [&](const std::string& url, const std::string&) { captured_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); }, "westus2"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }, "westus2"); client.FetchAllModels(); - EXPECT_EQ(captured_url, "https://ai.azure.com/api/westus2/ux/v1.0/entities/crossRegion"); + EXPECT_EQ(captured_url, "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); } TEST(AzureCatalogClientTest, NonRegionalUrlUsedVerbatimEvenWithRegion) { @@ -946,108 +890,23 @@ TEST(AzureCatalogClientTest, NonRegionalUrlUsedVerbatimEvenWithRegion) { std::string captured_url; AzureCatalogClient client("https://custom.example.com/catalog", "", ep, logger, [&](const std::string& url, const std::string&) { captured_url = url; - return MakeOkResponse( - R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"); }, "westus2"); + return MakeOkResponse(R"({"totalCount":0,"summaries":[],"continuationToken":""})"); }, "westus2"); client.FetchAllModels(); - EXPECT_EQ(captured_url, "https://custom.example.com/catalog/entities/crossRegion"); + EXPECT_EQ(captured_url, "https://custom.example.com/catalog"); } TEST(AzureCatalogClientTest, DetectedRegionStampedOnModels) { CpuOnlyEpDetector ep; StderrLogger logger; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, [&](const std::string&, const std::string&) { return MakeOkResponse(MakeMockCatalogResponse({{"phi-4-mini", 3}})); }, "westus2"); + AzureCatalogClient client(kAssetGalleryModelsUrl, "", ep, logger, [&](const std::string&, const std::string&) { return MakeOkResponse(MakeMockCatalogResponse({{"phi-4-mini", 3}})); }, "westus2"); auto infos = client.FetchAllModelInfos(); ASSERT_EQ(infos.size(), 1u); EXPECT_EQ(infos[0].detected_region, "westus2"); } -TEST(AzureCatalogClientTest, RegionStampedPerFilterSetAfterFallback) { - CpuGpuEpDetector ep; - StderrLogger logger; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, [&](const std::string& url, const std::string& body) { - http::HttpResponse resp; - if (body.find("\"cpu\"") != std::string::npos && url.find("/api/eastus/") != std::string::npos) { - resp.status = 503; - return resp; - } - - if (body.find("\"cpu\"") != std::string::npos && url.find("/api/eastus2/") != std::string::npos) { - resp.status = 200; - resp.body = MakeMockCatalogResponse({{"cpu-model", 1}}); - return resp; - } - - if (body.find("\"gpu\"") != std::string::npos && url.find("/api/eastus2/") != std::string::npos) { - resp.status = 503; - return resp; - } - - if (body.find("\"gpu\"") != std::string::npos && url.find("/api/eastus/") != std::string::npos) { - resp.status = 200; - resp.body = MakeMockCatalogResponse({{"gpu-model", 1}}); - return resp; - } - - resp.status = 500; - return resp; }, "eastus"); - - auto infos = client.FetchAllModelInfos(); - ASSERT_EQ(infos.size(), 2u); - - std::map region_by_id; - for (const auto& info : infos) { - region_by_id[info.model_id] = info.detected_region; - } - - EXPECT_EQ(region_by_id["cpu-model:1"], "eastus2"); - EXPECT_EQ(region_by_id["gpu-model:1"], "eastus"); -} - -// ======================================================================== -// Catalog region fallback -// ======================================================================== - -TEST(AzureCatalogClientTest, Fallback_RetriesNextRegionAndPinsPagination) { - CpuOnlyEpDetector ep; - StderrLogger logger; - std::vector attempted_urls; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, [&](const std::string& url, const std::string&) { - attempted_urls.push_back(url); - http::HttpResponse resp; - // First attempt (eastus) is region-unhealthy; subsequent regions are healthy. - if (attempted_urls.size() == 1) { - resp.status = 503; - return resp; - } - resp.status = 200; - resp.body = R"({"indexEntitiesResponse":{"totalCount":0,"value":[],"nextSkip":0,"continuationToken":""}})"; - return resp; }, "eastus"); - - auto models = client.FetchAllModels(); - ASSERT_GE(attempted_urls.size(), 2u); - EXPECT_TRUE(attempted_urls[0].find("/api/eastus/") != std::string::npos); - EXPECT_TRUE(attempted_urls[1].find("/api/eastus2/") != std::string::npos) - << "Expected fallback to the first proximal region. Got: " << attempted_urls[1]; -} - -TEST(AzureCatalogClientTest, Fallback_DisabledDoesNotRetry) { - CpuOnlyEpDetector ep; - StderrLogger logger; - int calls = 0; - AzureCatalogClient client("https://ai.azure.com/api/eastus/ux/v1.0", "", ep, logger, [&](const std::string&, const std::string&) { - ++calls; - http::HttpResponse resp; - resp.status = 503; // unhealthy, but fallback is off → no retry, filter set is skipped - return resp; }, "eastus", /*region_fallback_enabled=*/false); - - auto models = client.FetchAllModels(); - EXPECT_EQ(calls, 1); - EXPECT_TRUE(models.empty()); -} - -TEST(AzureCatalogClientTest, Fallback_PermanentCatalogErrorThrows) { +TEST(AzureCatalogClientTest, CatalogHttpFailureThrows) { CpuOnlyEpDetector ep; StderrLogger logger; int calls = 0; @@ -1066,7 +925,7 @@ TEST(AzureCatalogClientTest, Fallback_PermanentCatalogErrorThrows) { EXPECT_EQ(calls, 1); } -TEST(AzureCatalogClientTest, Fallback_MidPaginationFailureDoesNotCommitPartialFilterSet) { +TEST(AzureCatalogClientTest, MidPaginationFailureThrowsWithoutPartialCommit) { CpuOnlyEpDetector ep; StderrLogger logger; int calls = 0; @@ -1076,24 +935,19 @@ TEST(AzureCatalogClientTest, Fallback_MidPaginationFailureDoesNotCommitPartialFi if (calls == 1) { resp.status = 200; resp.body = R"({ - "indexEntitiesResponse": { - "totalCount": 1, - "value": [{ + "totalCount": 1, + "summaries": [{ "assetId": "azureml://registries/azureml/models/page-one-model/versions/1", - "entityId": "page-one-model:1", - "annotations": {"tags": {"alias": "page-one-model"}}, - "properties": { - "name": "page-one-model", - "version": 1, - "variantInfo": { + "alias": "page-one-model", + "name": "page-one-model", + "version": "1", + "variantInformation": { "parents": [], "variantMetadata": {"device": "cpu", "executionProvider": "CPUExecutionProvider"} } - } }], "nextSkip": 50, "continuationToken": "next" - } })"; return resp; } @@ -1101,7 +955,6 @@ TEST(AzureCatalogClientTest, Fallback_MidPaginationFailureDoesNotCommitPartialFi resp.status = 503; return resp; }, "eastus"); - auto models = client.FetchAllModels(); + EXPECT_THROW(client.FetchAllModels(), fl::Exception); EXPECT_EQ(calls, 2); - EXPECT_TRUE(models.empty()); } From 0436a2c41d7178e3db48f652d4ac03b15397670e Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 14 Jul 2026 15:44:08 -0700 Subject: [PATCH 2/6] remove region fallback --- sdk_v2/cpp/CMakeLists.txt | 1 - .../cpp/src/catalog/azure_catalog_client.cc | 11 +- sdk_v2/cpp/src/catalog/azure_catalog_client.h | 14 +- sdk_v2/cpp/src/catalog/azure_model_catalog.cc | 12 +- sdk_v2/cpp/src/catalog/azure_model_catalog.h | 4 +- sdk_v2/cpp/src/catalog/catalog_client.h | 4 +- sdk_v2/cpp/src/download/download_manager.cc | 5 +- sdk_v2/cpp/src/download/download_manager.h | 7 +- .../cpp/src/download/model_registry_client.cc | 16 +- .../cpp/src/download/model_registry_client.h | 7 +- sdk_v2/cpp/src/manager.cc | 15 +- sdk_v2/cpp/src/util/region_fallback.cc | 216 ------------------ sdk_v2/cpp/src/util/region_fallback.h | 66 ------ sdk_v2/cpp/test/CMakeLists.txt | 1 - sdk_v2/cpp/test/internal_api/download_test.cc | 104 +-------- .../test/internal_api/region_fallback_test.cc | 195 ---------------- 16 files changed, 30 insertions(+), 648 deletions(-) delete mode 100644 sdk_v2/cpp/src/util/region_fallback.cc delete mode 100644 sdk_v2/cpp/src/util/region_fallback.h delete mode 100644 sdk_v2/cpp/test/internal_api/region_fallback_test.cc diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index 0e6eb8133..4f57458bc 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -197,7 +197,6 @@ set(FOUNDRY_LOCAL_SOURCES src/util/file_lock.cc src/http/http_download.cc src/util/path_safety.cc - src/util/region_fallback.cc src/util/sha256.cc src/util/zip_extract.cc ${FOUNDRY_LOCAL_PLATFORM_SOURCES} diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_client.cc b/sdk_v2/cpp/src/catalog/azure_catalog_client.cc index f3e301e1c..c5f4bb237 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_client.cc +++ b/sdk_v2/cpp/src/catalog/azure_catalog_client.cc @@ -238,14 +238,12 @@ AzureCatalogClient::AzureCatalogClient(const std::string& base_url, const IEpDetector& ep_detector, ILogger& logger, HttpPostResponseFn http_post, - std::string catalog_region, - bool region_fallback_enabled) + std::string catalog_region) : base_url_(base_url), model_filter_(CreateModelFilter(filter_override)), ep_detector_(ep_detector), logger_(logger), - http_post_response_(std::move(http_post)), - region_fallback_(logger, region_fallback_enabled) { + http_post_response_(std::move(http_post)) { if (!http_post_response_) { http_post_response_ = [](const std::string& url, const std::string& body) { http::HttpRequestOptions options; @@ -387,11 +385,10 @@ std::unique_ptr MakeCatalogClient( const IEpDetector& ep_detector, ILogger& logger, const std::string& /*cache_directory*/, - const std::string& catalog_region, - bool disable_region_fallback) { + const std::string& catalog_region) { return std::make_unique(base_url, filter_override, ep_detector, logger, AzureCatalogClient::HttpPostResponseFn{}, - catalog_region, !disable_region_fallback); + catalog_region); } } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_client.h b/sdk_v2/cpp/src/catalog/azure_catalog_client.h index 5f80f6b3a..d46ecbdd9 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_client.h +++ b/sdk_v2/cpp/src/catalog/azure_catalog_client.h @@ -8,7 +8,6 @@ #include "http/http_client.h" #include "logger.h" #include "model_info.h" -#include "util/region_fallback.h" #include #include @@ -26,7 +25,7 @@ namespace fl { /// downloads can target the matching regional model registry. class AzureCatalogClient : public ICatalogClient { public: - /// Response-aware HTTP POST. Used for region detection, catalog fetches, and regional fallback. + /// Response-aware HTTP POST. Used for region detection and catalog fetches. using HttpPostResponseFn = std::function; @@ -36,14 +35,12 @@ class AzureCatalogClient : public ICatalogClient { /// @param logger Logger. /// @param http_post HTTP POST implementation. The default uses `http::HttpPostWithResponse`. /// @param catalog_region Catalog region. Empty or "auto" detects from Azure headers; any other value is explicit. - /// @param region_fallback_enabled Enables retries through nearby regions when a regional endpoint is unhealthy. AzureCatalogClient(const std::string& base_url, const std::string& filter_override, const IEpDetector& ep_detector, ILogger& logger, HttpPostResponseFn http_post = {}, - std::string catalog_region = "", - bool region_fallback_enabled = true); + std::string catalog_region = ""); /// Fetch every catalog model entry visible to the local hardware (raw form, /// before conversion to ModelInfo). One filter set per device, fully paginated. @@ -68,12 +65,10 @@ class AzureCatalogClient : public ICatalogClient { std::string region; }; - /// Run all pages of one filter set. In regional mode the first page goes through region fallback and later pages are - /// pinned to the serving region; a retryable region-health failure fails just this filter set (others continue). + /// Run all pages of one filter set. std::optional FetchFilterSet(const std::vector& filters); - /// Fetch every device filter set, dropping the ones that failed their region-health checks. - /// Used for unbounded "latest only" / by-id queries. + /// Fetch every device filter set. std::vector FetchAllFilterSets(); std::string base_url_; @@ -81,7 +76,6 @@ class AzureCatalogClient : public ICatalogClient { const IEpDetector& ep_detector_; ILogger& logger_; HttpPostResponseFn http_post_response_; - RegionFallback region_fallback_; // Region state used for model-registry routing after catalog discovery. std::string region_; diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc index 39afcae37..96684f3b2 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc @@ -22,8 +22,7 @@ AzureModelCatalog::AzureModelCatalog(std::vector(kDefaultCatalogFilter)); } @@ -95,7 +93,7 @@ std::vector AzureModelCatalog::FetchModels() const { // Preserve byte-identical behavior for the "no override" case (previously stored as ""), // while letting callers explicitly request "" as a real filter override. auto client = MakeCatalogClient(url, filter.value_or(""), ep_detector_, logger_, cache_dir, - catalog_region_, disable_region_fallback_); + catalog_region_); auto model_infos = FetchAllModelInfosWithCachedModels(*client, cached_model_ids, logger_); for (const auto& info : model_infos) { @@ -149,7 +147,7 @@ std::vector AzureModelCatalog::FetchModelVersions( for (const auto& [url, filter] : catalog_urls_) { try { auto client = MakeCatalogClient(url, filter.value_or(""), ep_detector_, logger_, cache_dir_, - catalog_region_, disable_region_fallback_); + catalog_region_); auto model_infos = client->FetchAllVersionsByAlias(model_alias, model_name); out.reserve(out.size() + model_infos.size()); @@ -194,7 +192,7 @@ std::vector AzureModelCatalog::FetchModelsByIds(const std::vectorFetchModelsByIds(remaining); for (auto& info : model_infos) { diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.h b/sdk_v2/cpp/src/catalog/azure_model_catalog.h index ef1438450..cfa58db94 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.h @@ -27,8 +27,7 @@ class AzureModelCatalog : public BaseModelCatalog { const IEpDetector& ep_detector, ILogger& logger, bool cache_only = false, - std::string catalog_region = "", - bool disable_region_fallback = false); + std::string catalog_region = ""); ~AzureModelCatalog() override; protected: @@ -49,7 +48,6 @@ class AzureModelCatalog : public BaseModelCatalog { bool cache_only_; // Configured Azure region: empty/"auto" → auto-detect, explicit → hard override. std::string catalog_region_; - bool disable_region_fallback_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/catalog_client.h b/sdk_v2/cpp/src/catalog/catalog_client.h index 54fa437fd..73f24d929 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.h +++ b/sdk_v2/cpp/src/catalog/catalog_client.h @@ -61,14 +61,12 @@ std::vector FetchAllModelInfosWithCachedModels( /// - `filter_override` overrides the deploymentOptions filter (default `foundryLocalDevices`). /// - `catalog_region` controls regional routing: empty/"auto" means detect it, /// any other value is an explicit region. -/// - `disable_region_fallback` disables cross-region retries. std::unique_ptr MakeCatalogClient( const std::string& base_url, const std::string& filter_override, const IEpDetector& ep_detector, ILogger& logger, const std::string& cache_directory, - const std::string& catalog_region = "", - bool disable_region_fallback = false); + const std::string& catalog_region = ""); } // namespace fl diff --git a/sdk_v2/cpp/src/download/download_manager.cc b/sdk_v2/cpp/src/download/download_manager.cc index c4f9dc569..b0ca5a0a1 100644 --- a/sdk_v2/cpp/src/download/download_manager.cc +++ b/sdk_v2/cpp/src/download/download_manager.cc @@ -7,7 +7,6 @@ #include "log_level.h" #include "logger.h" #include "util/path_safety.h" -#include "util/region_fallback.h" #include "utils.h" #include @@ -177,13 +176,13 @@ std::string ResolveRegion(const std::string& config_region, const ModelInfo& inf } // anonymous namespace DownloadManager::DownloadManager(std::string cache_directory, std::string_view catalog_region, int max_concurrency, - ILogger& logger, bool disable_region_fallback) + ILogger& logger) : cache_directory_(std::move(cache_directory)), config_region_(NormalizeConfiguredRegion(catalog_region)), max_concurrency_(max_concurrency), logger_(logger), registry_client_(std::make_unique( - kDefaultRegistryRegion, logger, std::make_unique(logger, !disable_region_fallback))), + kDefaultRegistryRegion, logger)), blob_downloader_(std::make_unique(logger)) {} DownloadManager::~DownloadManager() = default; diff --git a/sdk_v2/cpp/src/download/download_manager.h b/sdk_v2/cpp/src/download/download_manager.h index 7099dcb8a..117ecd754 100644 --- a/sdk_v2/cpp/src/download/download_manager.h +++ b/sdk_v2/cpp/src/download/download_manager.h @@ -29,14 +29,11 @@ class DownloadManager { /// @param catalog_region Explicit Azure region override for the model registry endpoint, /// or "auto"/empty to use each model's detected region (falling back to the default registry region). /// @param max_concurrency Per-blob chunk parallelism (default 64). - /// @param logger Logger forwarded to the registry client for retry diagnostics. - /// @param disable_region_fallback When true, the registry uses a single region attempt - /// with no cross-region fallback. + /// @param logger Logger forwarded to the registry client. DownloadManager(std::string cache_directory, std::string_view catalog_region, int max_concurrency, - ILogger& logger, - bool disable_region_fallback = false); + ILogger& logger); ~DownloadManager(); /// Override the model registry client (for testing). diff --git a/sdk_v2/cpp/src/download/model_registry_client.cc b/sdk_v2/cpp/src/download/model_registry_client.cc index 334b7da75..3f85904ce 100644 --- a/sdk_v2/cpp/src/download/model_registry_client.cc +++ b/sdk_v2/cpp/src/download/model_registry_client.cc @@ -9,7 +9,6 @@ #include "exception.h" #include "http/http_client.h" #include "logger.h" -#include "util/region_fallback.h" #include "utils.h" namespace fl { @@ -45,12 +44,8 @@ constexpr const char* kUserAgent = "AzureAiStudio"; ModelRegistryClient::ModelRegistryClient(std::string region, ILogger& /*logger*/, - std::unique_ptr fallback, HttpGetResponseFn http_get) - : default_region_(std::move(region)), http_get_(std::move(http_get)), fallback_(std::move(fallback)) { - // Default transport: status-aware GET (non-throwing) so the fallback engine can - // classify region-health failures. Cross-region fallback provides resilience - // in place of in-region retries when enabled. + : default_region_(std::move(region)), http_get_(std::move(http_get)) { if (!http_get_) { http_get_ = [](const std::string& url) { http::HttpRequestOptions options; @@ -70,14 +65,7 @@ ModelContainer ModelRegistryClient::ResolveModelContainer(const std::string& ass const std::string resolved_region = ToLower(has_per_call_region ? region : default_region_); const std::string encoded = UrlEncode(asset_id); - auto attempt = [&](const std::string& r) -> http::HttpResponse { - return http_get_(BuildBaseUrl(r) + encoded); - }; - - // A per-call region comes from explicit config or the model's detected catalog region and must take precedence. - // Sticky only biases calls that fall back to the client's default region. - const std::string start = has_per_call_region ? resolved_region : fallback_->StickyRegion().value_or(resolved_region); - http::HttpResponse response = fallback_->Execute(start, attempt).response; + http::HttpResponse response = http_get_(BuildBaseUrl(resolved_region) + encoded); if (response.status == 0 || response.status < 200 || response.status >= 300) { FL_THROW(FOUNDRY_LOCAL_ERROR_NETWORK, diff --git a/sdk_v2/cpp/src/download/model_registry_client.h b/sdk_v2/cpp/src/download/model_registry_client.h index 6527baade..dcda43ac9 100644 --- a/sdk_v2/cpp/src/download/model_registry_client.h +++ b/sdk_v2/cpp/src/download/model_registry_client.h @@ -11,10 +11,8 @@ namespace fl { class ILogger; -class RegionFallback; -/// Response-aware HTTP GET (status + headers + body) — lets the region-fallback -/// engine classify region-health failures by status code. +/// Response-aware HTTP GET (status + headers + body). using HttpGetResponseFn = std::function; /// Result from resolving a model's asset ID against the Azure model registry. @@ -31,11 +29,9 @@ class ModelRegistryClient { /// Used when ResolveModelContainer is called without a per-call region. /// @param logger Logger used for diagnostics. Tests that override the HTTP seam with a /// synchronous fake can pass a sink logger. - /// @param fallback Region-fallback engine. It can be constructed as disabled when only one region should be tried. /// @param http_get HTTP GET implementation. The default uses `http::HttpGetWithResponse`. ModelRegistryClient(std::string region, ILogger& logger, - std::unique_ptr fallback, HttpGetResponseFn http_get = {}); /// Resolve a model's asset_id (URI) to a blob storage SAS URI. @@ -51,7 +47,6 @@ class ModelRegistryClient { std::string default_region_; HttpGetResponseFn http_get_; - std::unique_ptr fallback_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 23c6ccb3f..2772908e9 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -60,11 +60,6 @@ bool IsGenAIVerboseLoggingEnabled() { return IsTruthyConfigValue(*env); } -bool IsAdditionalOptionEnabled(const Configuration& config, const std::string& option_name) { - const auto it = config.additional_options.find(option_name); - return it != config.additional_options.cend() && IsTruthyConfigValue(it->second); -} - OrtLoggingLevel GetDefaultOrtLoggingLevel(bool genai_verbose_logging_enabled) { // If someone explicitly enables ORTGENAI_ORT_VERBOSE_LOGGING, treat this as // a debug scenario and default ORT logging to verbose as well. @@ -311,16 +306,11 @@ Manager::Manager(const Configuration& config) } } - // Read whether cross-region fallback should be disabled (default: enabled). - // Accepts case-insensitive true/1/yes. - const bool disable_region_fallback = IsAdditionalOptionEnabled(config_, "DisableRegionFallback"); - download_manager_ = std::make_unique( *config_.model_cache_dir, config_.catalog_region.value_or("auto"), download_concurrency, - *logger_, - disable_region_fallback); + *logger_); model_load_manager_ = std::make_unique(*ep_detector_, *logger_); session_manager_ = std::make_unique(*logger_); telemetry_ = std::make_unique(config_.app_name, *logger_); @@ -332,8 +322,7 @@ Manager::Manager(const Configuration& config) }, *ep_detector_, *logger_, config_.external_service_url.has_value(), - config_.catalog_region.value_or("auto"), - disable_region_fallback); + config_.catalog_region.value_or("auto")); } Manager::~Manager() { diff --git a/sdk_v2/cpp/src/util/region_fallback.cc b/sdk_v2/cpp/src/util/region_fallback.cc deleted file mode 100644 index ee6e4d46d..000000000 --- a/sdk_v2/cpp/src/util/region_fallback.cc +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -#include "util/region_fallback.h" - -#include "exception.h" -#include "utils.h" - -#include - -#include -#include -#include -#include -#include -#include - -namespace fl { - -namespace { - -/// Build the proximal-region map once. -std::map> BuildProximalRegions() { - std::map> map = { - // Americas: United States - {"eastus", {"eastus2", "centralus", "southcentralus", "westus2", "westeurope"}}, - {"eastus2", {"eastus", "centralus", "southcentralus", "westus2", "westeurope"}}, - {"centralus", {"southcentralus", "northcentralus", "eastus", "westus2", "westeurope"}}, - {"northcentralus", {"centralus", "southcentralus", "eastus", "westus2", "westeurope"}}, - {"southcentralus", {"centralus", "northcentralus", "eastus", "westus2", "westeurope"}}, - {"westus", {"westus2", "westus3", "westcentralus", "centralus", "eastus", "westeurope"}}, - {"westus2", {"westus3", "westus", "westcentralus", "centralus", "eastus", "westeurope"}}, - {"westus3", {"westus2", "westus", "westcentralus", "centralus", "eastus", "westeurope"}}, - {"westcentralus", {"westus2", "westus3", "westus", "centralus", "eastus", "westeurope"}}, - - // Americas: Canada - {"canadacentral", {"canadaeast", "eastus", "eastus2", "westeurope"}}, - {"canadaeast", {"canadacentral", "eastus", "eastus2", "westeurope"}}, - - // Americas: Brazil - {"brazilsouth", {"southcentralus", "eastus", "westeurope"}}, - - // Europe: Western - {"westeurope", - {"northeurope", "francecentral", "germanywestcentral", "uksouth", "swedencentral", "eastus"}}, - {"northeurope", {"westeurope", "uksouth", "francecentral", "swedencentral", "eastus"}}, - {"francecentral", - {"westeurope", "northeurope", "germanywestcentral", "switzerlandnorth", "eastus"}}, - {"germanywestcentral", - {"westeurope", "francecentral", "switzerlandnorth", "polandcentral", "northeurope", "eastus"}}, - {"switzerlandnorth", - {"switzerlandwest", "germanywestcentral", "francecentral", "westeurope", "italynorth", "eastus"}}, - {"switzerlandwest", - {"switzerlandnorth", "francecentral", "italynorth", "germanywestcentral", "westeurope", "eastus"}}, - {"italynorth", {"switzerlandnorth", "francecentral", "westeurope", "spaincentral", "eastus"}}, - {"spaincentral", {"francecentral", "westeurope", "italynorth", "eastus"}}, - - // Europe: UK - {"uksouth", {"ukwest", "northeurope", "westeurope", "francecentral", "eastus"}}, - {"ukwest", {"uksouth", "northeurope", "westeurope", "francecentral", "eastus"}}, - - // Europe: Nordics - {"swedencentral", {"norwayeast", "northeurope", "westeurope", "eastus"}}, - {"norwayeast", {"swedencentral", "northeurope", "westeurope", "eastus"}}, - - // Europe: Eastern - {"polandcentral", - {"germanywestcentral", "swedencentral", "westeurope", "northeurope", "eastus"}}, - - // Middle East - {"uaenorth", {"qatarcentral", "israelcentral", "westeurope", "northeurope", "eastus"}}, - {"qatarcentral", {"uaenorth", "israelcentral", "westeurope", "northeurope", "eastus"}}, - {"israelcentral", {"uaenorth", "qatarcentral", "westeurope", "northeurope", "eastus"}}, - - // Africa - {"southafricanorth", {"westeurope", "northeurope", "uaenorth", "eastus"}}, - - // Asia Pacific: East Asia - {"japaneast", {"japanwest", "koreacentral", "taiwannorth", "eastasia", "southeastasia", "westus2"}}, - {"japanwest", {"japaneast", "koreacentral", "taiwannorth", "eastasia", "southeastasia", "westus2"}}, - {"koreacentral", {"japaneast", "japanwest", "taiwannorth", "eastasia", "southeastasia", "westus2"}}, - {"eastasia", {"taiwannorth", "southeastasia", "japaneast", "koreacentral", "australiaeast", "westus2"}}, - {"taiwannorth", {"eastasia", "japaneast", "koreacentral", "southeastasia", "westus2"}}, - - // Asia Pacific: Southeast Asia - {"southeastasia", {"malaysiawest", "eastasia", "japaneast", "australiaeast", "centralindia", "westus2"}}, - {"malaysiawest", {"southeastasia", "eastasia", "centralindia", "japaneast", "westus2"}}, - - // Asia Pacific: India - {"centralindia", {"southindia", "jioindiawest", "southeastasia", "uaenorth", "westeurope", "eastus"}}, - {"southindia", {"centralindia", "jioindiawest", "southeastasia", "uaenorth", "westeurope", "eastus"}}, - {"jioindiawest", {"centralindia", "southindia", "southeastasia", "uaenorth", "westeurope", "eastus"}}, - - // Oceania - {"australiaeast", {"australiasoutheast", "southeastasia", "japaneast", "westus2"}}, - {"australiasoutheast", {"australiaeast", "southeastasia", "japaneast", "westus2"}}, - }; - - return map; -} - -/// Proximal-region lookup table, built once on first use. -const std::map>& ProximalRegions() { - static const std::map> kMap = BuildProximalRegions(); - return kMap; -} - -std::string DescribeStatus(int status) { - return status == 0 ? "transport failure" : ("HTTP " + std::to_string(status)); -} - -} // namespace - -bool IsRegionRetryableStatus(int status) { - return status == 0 || status == 408 || status == 429 || status == 500 || status == 502 || - status == 503 || status == 504; -} - -RegionFallback::RegionFallback(ILogger& logger, bool enabled, RandomPicker random_picker) - : logger_(logger), enabled_(enabled), random_picker_(std::move(random_picker)) { - if (random_picker_) { - return; - } - - // Default RNG: per-instance, seeded from std::random_device. Jitter need not be strong. - auto rng = std::make_shared( - static_cast(std::random_device{}())); - random_picker_ = [rng](std::size_t count) -> std::size_t { - if (count == 0) { - return 0; - } - - return static_cast((*rng)() % count); - }; -} - -std::vector BuildRegionFallbackChain(const std::string& start_region, - const RegionFallback::RandomPicker& picker) { - std::vector chain; - std::set seen; - - auto add = [&](const std::string& region) { - if (region.empty()) { - return; - } - - auto normalized = ToLower(region); - if (seen.insert(normalized).second) { - chain.push_back(normalized); - } - }; - - const auto start = ToLower(start_region); - add(start); - - const auto& proximal_map = ProximalRegions(); - auto it = proximal_map.find(start); - if (it != proximal_map.end()) { - for (const auto& p : it->second) { - add(p); - } - } - - // Last-ditch: one random known public region not already in the chain. - std::vector remaining; - for (const auto& [region, _] : proximal_map) { - if (seen.find(region) == seen.end()) { - remaining.push_back(region); - } - } - - if (!remaining.empty() && picker) { - add(remaining[picker(remaining.size())]); - } - - return chain; -} - -FallbackResult RegionFallback::Execute(const std::string& start_region, const AttemptFn& attempt) { - const auto normalized_start = ToLower(start_region); - - if (!enabled_) { - return FallbackResult{attempt(normalized_start), normalized_start}; - } - - const auto chain = BuildRegionFallbackChain(normalized_start, random_picker_); - std::string failure_summary; - - for (const auto& region : chain) { - http::HttpResponse response = attempt(region); - - if (!IsRegionRetryableStatus(response.status)) { - // Non-retryable: a 2xx success OR a permanent error (e.g. 404). Either way, stop here and let the caller - // inspect the status. Record the sticky region only on success so a permanent error doesn't pin a bad region. - if (response.status >= 200 && response.status < 300) { - sticky_ = region; - } - - return FallbackResult{std::move(response), region}; - } - - if (!failure_summary.empty()) { - failure_summary += ", "; - } - - failure_summary += region + "(" + DescribeStatus(response.status) + ")"; - logger_.Log(LogLevel::Warning, - "RegionFallback: region '" + region + "' unhealthy (" + - DescribeStatus(response.status) + "); trying next candidate."); - } - - FL_THROW(FOUNDRY_LOCAL_ERROR_NETWORK, - "region fallback exhausted all " + std::to_string(chain.size()) + - " candidate region(s): " + failure_summary); -} - -} // namespace fl diff --git a/sdk_v2/cpp/src/util/region_fallback.h b/sdk_v2/cpp/src/util/region_fallback.h deleted file mode 100644 index 6f7cbf85d..000000000 --- a/sdk_v2/cpp/src/util/region_fallback.h +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -#pragma once - -#include "http/http_client.h" -#include "logger.h" - -#include -#include -#include -#include - -namespace fl { - -/// Outcome of a fallback run: the response and the region that produced it. -struct FallbackResult { - http::HttpResponse response; - std::string region; -}; - -/// True for statuses that indicate a region-health failure (retryable): -/// 0 (transport), 408, 429, 500, 502, 503, 504. -bool IsRegionRetryableStatus(int status); - -/// Build the ordered fallback chain: [start] + proximal[start] + one random unused public region. -/// Deduplicated in order; all regions lowercased. -std::vector BuildRegionFallbackChain(const std::string& start_region, - const std::function& picker); - -/// Executes a regional HTTP operation against an ordered chain of candidate Azure -/// regions when the preferred region is unhealthy. Classifies failures by HTTP -/// status; status==0 means a transport failure before any HTTP response arrived. -/// -/// The sticky region (last success) and the RNG are per-instance state (no global mutable state). -class RegionFallback { - public: - /// Performs one HTTP attempt against `region`, returning the full response - /// (status==0 means transport failure). - using AttemptFn = std::function; - - /// Picks an index in [0, count) — injected for deterministic tests. - using RandomPicker = std::function; - - /// @param logger Diagnostics for fallback decisions. - /// @param enabled When false, runs a single attempt against the start region with no fallback. - /// @param random_picker Picks the last-ditch random region. The default uses a per-instance RNG. - explicit RegionFallback(ILogger& logger, bool enabled = true, RandomPicker random_picker = {}); - - /// Execute `attempt` across the candidate chain for `start_region`. - /// - First non-retryable response (2xx or a permanent error like 404) is returned - /// immediately along with its region; the caller inspects the status. - /// - Retryable responses (status 0/408/429/5xx) advance to the next candidate. - /// - If every candidate is retryable-failed, throws fl::Exception with the chain. - FallbackResult Execute(const std::string& start_region, const AttemptFn& attempt); - - /// Last region that successfully served a request, if any. - std::optional StickyRegion() const { return sticky_; } - - private: - ILogger& logger_; - bool enabled_; - RandomPicker random_picker_; - std::optional sticky_; -}; - -} // namespace fl diff --git a/sdk_v2/cpp/test/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index c83d0c5b9..ab9c48132 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -47,7 +47,6 @@ add_executable(foundry_local_tests internal_api/responses_json_test.cc internal_api/session_manager_test.cc internal_api/sha256_test.cc - internal_api/region_fallback_test.cc internal_api/sse_stream_body_test.cc internal_api/azure_catalog_test.cc internal_api/telemetry_test.cc diff --git a/sdk_v2/cpp/test/internal_api/download_test.cc b/sdk_v2/cpp/test/internal_api/download_test.cc index 96ce4f543..4b1856a3a 100644 --- a/sdk_v2/cpp/test/internal_api/download_test.cc +++ b/sdk_v2/cpp/test/internal_api/download_test.cc @@ -20,7 +20,6 @@ #include "model_info.h" #include "test_helpers.h" #include "util/path_safety.h" -#include "util/region_fallback.h" #include #include #include @@ -230,7 +229,6 @@ class AllDevicesEpDetector : public IEpDetector { TEST(ModelRegistryClientTest, ResolvesModelContainerFromJson) { ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [](const std::string& url) { EXPECT_TRUE(url.find("assetId=") != std::string::npos); return MakeRegistryResponse(R"({ @@ -248,14 +246,12 @@ TEST(ModelRegistryClientTest, ResolvesModelContainerFromJson) { } TEST(ModelRegistryClientTest, ThrowsOnEmptyAssetId) { - ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false)); + ModelRegistryClient client("eastus", fl::test::NullLog()); EXPECT_THROW(client.ResolveModelContainer(""), fl::Exception); } TEST(ModelRegistryClientTest, ThrowsNetworkOnHttpFailure) { ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse("upstream error", 500); }); @@ -269,7 +265,6 @@ TEST(ModelRegistryClientTest, ThrowsNetworkOnHttpFailure) { TEST(ModelRegistryClientTest, ThrowsOnMissingSasUri) { ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse(R"({"modelEntity": {"description": "no sas uri"}})"); }); @@ -285,21 +280,18 @@ TEST(ModelRegistryClientTest, ThrowsOnMissingSasUri) { TEST(ModelRegistryClientTest, ThrowsOnEmptyResponse) { ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse(""); }); EXPECT_THROW(client.ResolveModelContainer("azureml://test"), fl::Exception); } TEST(ModelRegistryClientTest, ThrowsOnMalformedJson) { ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse("not json"); }); EXPECT_THROW(client.ResolveModelContainer("azureml://test"), fl::Exception); } TEST(ModelRegistryClientTest, HandlesOptionalDescription) { ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse(R"({"blobSasUri": "https://example.com/blob?sig=x"})"); }); @@ -311,7 +303,6 @@ TEST(ModelRegistryClientTest, HandlesOptionalDescription) { TEST(ModelRegistryClientTest, UrlEncodesAssetId) { std::string captured_url; ModelRegistryClient client("eastus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [&captured_url](const std::string& url) { captured_url = url; return MakeRegistryResponse(R"({"blobSasUri": "https://example.com/blob"})"); @@ -325,7 +316,6 @@ TEST(ModelRegistryClientTest, UrlEncodesAssetId) { TEST(ModelRegistryClientTest, Region_DefaultIsCentralUs) { std::string captured_url; ModelRegistryClient client("centralus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [&captured_url](const std::string& url) { captured_url = url; return MakeRegistryResponse(R"({"blobSasUri": "https://example.com/blob"})"); @@ -338,7 +328,6 @@ TEST(ModelRegistryClientTest, Region_DefaultIsCentralUs) { TEST(ModelRegistryClientTest, Region_PerCallOverridesDefault) { std::string captured_url; ModelRegistryClient client("centralus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [&captured_url](const std::string& url) { captured_url = url; return MakeRegistryResponse(R"({"blobSasUri": "https://example.com/blob"})"); @@ -354,7 +343,6 @@ TEST(ModelRegistryClientTest, Region_PerCallOverridesDefault) { TEST(ModelRegistryClientTest, Region_EmptyPerCallUsesDefault) { std::string captured_url; ModelRegistryClient client("centralus", fl::test::NullLog(), - std::make_unique(fl::test::NullLog(), false), [&captured_url](const std::string& url) { captured_url = url; return MakeRegistryResponse(R"({"blobSasUri": "https://example.com/blob"})"); @@ -364,86 +352,6 @@ TEST(ModelRegistryClientTest, Region_EmptyPerCallUsesDefault) { << "Expected empty per-call region to fall back to the default. Got: " << captured_url; } -TEST(ModelRegistryClientTest, Fallback_RetriesNextRegionOnRegionHealthFailure) { - auto fallback = - std::make_unique(fl::test::NullLog(), true, [](std::size_t) { return std::size_t{0}; }); - auto* fallback_observer = fallback.get(); - - std::vector attempted_urls; - ModelRegistryClient client("eastus", fl::test::NullLog(), std::move(fallback), [&](const std::string& url) { - attempted_urls.push_back(url); - http::HttpResponse resp; - if (attempted_urls.size() == 1) { - resp.status = 503; // first region (eastus) unhealthy - return resp; - } - resp.status = 200; // proximal region recovers - resp.body = R"({"blobSasUri": "https://example.com/blob"})"; - return resp; - }); - - auto container = client.ResolveModelContainer("azureml://test", "eastus"); - EXPECT_EQ(container.blob_sas_uri, "https://example.com/blob"); - ASSERT_EQ(attempted_urls.size(), 2u); - EXPECT_TRUE(attempted_urls[0].find("eastus.api.azureml.ms") != std::string::npos); - EXPECT_TRUE(attempted_urls[1].find("eastus2.api.azureml.ms") != std::string::npos) - << "Expected fallback to the first proximal region. Got: " << attempted_urls[1]; - - // The healthy region becomes sticky for subsequent registry calls. - auto sticky = fallback_observer->StickyRegion(); - ASSERT_TRUE(sticky.has_value()); - EXPECT_EQ(*sticky, "eastus2"); -} - -TEST(ModelRegistryClientTest, Fallback_PermanentErrorThrowsWithoutRetry) { - auto fallback = - std::make_unique(fl::test::NullLog(), true, [](std::size_t) { return std::size_t{0}; }); - - int calls = 0; - ModelRegistryClient client("eastus", fl::test::NullLog(), std::move(fallback), [&](const std::string&) { - ++calls; - http::HttpResponse resp; - resp.status = 404; // permanent — must not trigger cross-region retries - return resp; - }); - - EXPECT_THROW(client.ResolveModelContainer("azureml://test", "eastus"), fl::Exception); - EXPECT_EQ(calls, 1); -} - -TEST(ModelRegistryClientTest, Fallback_PerCallRegionOverridesStickyRegion) { - auto fallback = - std::make_unique(fl::test::NullLog(), true, [](std::size_t) { return std::size_t{0}; }); - auto* fallback_observer = fallback.get(); - - std::vector attempted_urls; - ModelRegistryClient client("eastus", fl::test::NullLog(), std::move(fallback), [&](const std::string& url) { - attempted_urls.push_back(url); - http::HttpResponse resp; - if (attempted_urls.size() == 1) { - resp.status = 503; - return resp; - } - - resp.status = 200; - resp.body = R"({"blobSasUri": "https://example.com/blob"})"; - return resp; - }); - - client.ResolveModelContainer("azureml://first", "eastus"); - auto sticky = fallback_observer->StickyRegion(); - ASSERT_TRUE(sticky.has_value()); - EXPECT_EQ(*sticky, "eastus2"); - - attempted_urls.clear(); - client.ResolveModelContainer("azureml://second", "westeurope"); - - ASSERT_FALSE(attempted_urls.empty()); - EXPECT_TRUE(attempted_urls.front().find("westeurope.api.azureml.ms") != std::string::npos) - << "Expected explicit per-call region to start at westeurope despite sticky region. Got: " - << attempted_urls.front(); -} - // ======================================================================== // Blob download orchestration tests // ======================================================================== @@ -882,7 +790,7 @@ TEST(DownloadManagerTest, FullDownloadFlow) { // Mock the registry client auto registry = std::make_unique( - "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), + "eastus", fl::test::NullLog(), [](const std::string&) { return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/container?sig=test"})"); @@ -934,7 +842,7 @@ static std::string CaptureRegistryUrlForDownload(const std::string& config_regio std::string captured_url; auto registry = std::make_unique( - "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), + "eastus", fl::test::NullLog(), [&captured_url](const std::string& url) { captured_url = url; return MakeRegistryResponse( @@ -1110,7 +1018,7 @@ TEST(DownloadManagerTest, ConcurrentDownloadsOfSameModelSerialize) { DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); auto registry = std::make_unique( - "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), + "eastus", fl::test::NullLog(), [](const std::string&) { return MakeRegistryResponse(R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})"); }); @@ -1194,7 +1102,7 @@ TEST(DownloadManagerTest, ModelDownloadsSerializeUnderGlobalLock) { DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); auto registry = std::make_unique( - "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), + "eastus", fl::test::NullLog(), [](const std::string&) { return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})"); @@ -1287,7 +1195,7 @@ TEST(DownloadManagerTest, WaitsForCrossProcessLockThenServesCachedResult) { // Registry + downloader that must stay untouched if the post-lock recheck works. auto registry = std::make_unique( - "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), + "eastus", fl::test::NullLog(), [](const std::string&) { return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})"); diff --git a/sdk_v2/cpp/test/internal_api/region_fallback_test.cc b/sdk_v2/cpp/test/internal_api/region_fallback_test.cc deleted file mode 100644 index d649bf931..000000000 --- a/sdk_v2/cpp/test/internal_api/region_fallback_test.cc +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// -// Tests for the region-fallback engine: -// - candidate-chain construction (start + proximal + random tail, deduped) -// - retryable vs permanent status classification -// - sticky-region recording per endpoint -// - fallback to the next healthy region, and exhaustion behavior -// -#include "util/region_fallback.h" - -#include "exception.h" -#include "http/http_client.h" -#include "logger.h" - -#include - -#include -#include -#include - -using namespace fl; - -namespace { - -http::HttpResponse Resp(int status) { - http::HttpResponse r; - r.status = status; - r.body = "{}"; - return r; -} - -// Deterministic picker: always selects the first remaining region. -RegionFallback::RandomPicker FirstPicker() { - return [](std::size_t) -> std::size_t { return 0; }; -} - -} // namespace - -// ======================================================================== -// Candidate chain -// ======================================================================== - -TEST(RegionFallbackTest, ChainStartsWithStartThenProximalThenRandom) { - auto chain = BuildRegionFallbackChain("eastus", FirstPicker()); - - ASSERT_FALSE(chain.empty()); - EXPECT_EQ(chain.front(), "eastus"); - // eastus proximal: eastus2, centralus, southcentralus, westus2, westeurope - EXPECT_EQ(chain[1], "eastus2"); - EXPECT_EQ(chain[2], "centralus"); - // The last element is the random tail (a known public region not already present). - EXPECT_GE(chain.size(), 7u); -} - -TEST(RegionFallbackTest, ChainIsDedupedAndLowercased) { - auto chain = BuildRegionFallbackChain("EastUS", FirstPicker()); - EXPECT_EQ(chain.front(), "eastus"); - - std::set unique(chain.begin(), chain.end()); - EXPECT_EQ(unique.size(), chain.size()) << "chain must contain no duplicates"; -} - -TEST(RegionFallbackTest, UnknownStartRegionStillProducesChain) { - auto chain = BuildRegionFallbackChain("madeupregion", FirstPicker()); - ASSERT_FALSE(chain.empty()); - EXPECT_EQ(chain.front(), "madeupregion"); - // No proximal entry, but the random tail still appends one known region. - EXPECT_GE(chain.size(), 2u); -} - -// ======================================================================== -// Status classification -// ======================================================================== - -TEST(RegionFallbackTest, RetryableStatusClassification) { - for (int s : {0, 408, 429, 500, 502, 503, 504}) { - EXPECT_TRUE(IsRegionRetryableStatus(s)) << "status " << s << " should be retryable"; - } - - for (int s : {200, 201, 400, 401, 403, 404}) { - EXPECT_FALSE(IsRegionRetryableStatus(s)) << "status " << s << " should be permanent"; - } -} - -// ======================================================================== -// Execute -// ======================================================================== - -TEST(RegionFallbackTest, SuccessOnFirstRegionRecordsSticky) { - StderrLogger logger; - RegionFallback fallback(logger, true, FirstPicker()); - - std::vector attempted; - auto result = fallback.Execute("eastus", - [&](const std::string& region) { - attempted.push_back(region); - return Resp(200); - }); - - EXPECT_EQ(result.region, "eastus"); - EXPECT_EQ(result.response.status, 200); - ASSERT_EQ(attempted.size(), 1u); - EXPECT_EQ(attempted[0], "eastus"); - - auto sticky = fallback.StickyRegion(); - ASSERT_TRUE(sticky.has_value()); - EXPECT_EQ(*sticky, "eastus"); -} - -TEST(RegionFallbackTest, FallsThroughToNextHealthyRegion) { - StderrLogger logger; - RegionFallback fallback(logger, true, FirstPicker()); - - std::vector attempted; - auto result = fallback.Execute("eastus", - [&](const std::string& region) { - attempted.push_back(region); - // First region 503, second succeeds. - return Resp(attempted.size() == 1 ? 503 : 200); - }); - - EXPECT_EQ(result.response.status, 200); - EXPECT_EQ(result.region, "eastus2"); // second candidate in eastus chain - EXPECT_EQ(attempted[0], "eastus"); - EXPECT_EQ(attempted[1], "eastus2"); - - auto sticky = fallback.StickyRegion(); - ASSERT_TRUE(sticky.has_value()); - EXPECT_EQ(*sticky, "eastus2"); -} - -TEST(RegionFallbackTest, PermanentErrorStopsImmediatelyAndDoesNotPinSticky) { - StderrLogger logger; - RegionFallback fallback(logger, true, FirstPicker()); - - int calls = 0; - auto result = fallback.Execute("eastus", - [&](const std::string&) { - ++calls; - return Resp(404); - }); - - EXPECT_EQ(result.response.status, 404); - EXPECT_EQ(calls, 1) << "a permanent error must not try other regions"; - EXPECT_FALSE(fallback.StickyRegion().has_value()); -} - -TEST(RegionFallbackTest, ExhaustingAllRegionsThrows) { - StderrLogger logger; - RegionFallback fallback(logger, true, FirstPicker()); - - int calls = 0; - try { - fallback.Execute("eastus", - [&](const std::string&) { - ++calls; - return Resp(503); - }); - FAIL() << "expected fl::Exception"; - } catch (const fl::Exception& e) { - EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_NETWORK); - } - - EXPECT_GE(calls, 7) << "every candidate region should have been attempted"; -} - -TEST(RegionFallbackTest, DisabledRunsSingleAttemptNoFallback) { - StderrLogger logger; - RegionFallback fallback(logger, /*enabled=*/false); - - int calls = 0; - auto result = fallback.Execute("eastus", - [&](const std::string& region) { - ++calls; - EXPECT_EQ(region, "eastus"); - return Resp(503); // even a retryable failure isn't retried - }); - - EXPECT_EQ(calls, 1); - EXPECT_EQ(result.response.status, 503); - EXPECT_EQ(result.region, "eastus"); -} - -TEST(RegionFallbackTest, StickyUpdatesToLastSuccessfulRegion) { - StderrLogger logger; - RegionFallback fallback(logger, true, FirstPicker()); - - fallback.Execute("eastus", [&](const std::string&) { return Resp(200); }); - ASSERT_TRUE(fallback.StickyRegion().has_value()); - EXPECT_EQ(*fallback.StickyRegion(), "eastus"); - - fallback.Execute("westus2", [&](const std::string&) { return Resp(200); }); - EXPECT_EQ(*fallback.StickyRegion(), "westus2"); -} From 0478380716a7d241005397d7c40570eae4c38ff3 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Thu, 16 Jul 2026 11:38:56 -0700 Subject: [PATCH 3/6] filters --- .../cpp/src/catalog/azure_catalog_client.cc | 66 ++++++++----------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/sdk_v2/cpp/src/catalog/azure_catalog_client.cc b/sdk_v2/cpp/src/catalog/azure_catalog_client.cc index c5f4bb237..6a3348af2 100644 --- a/sdk_v2/cpp/src/catalog/azure_catalog_client.cc +++ b/sdk_v2/cpp/src/catalog/azure_catalog_client.cc @@ -167,46 +167,34 @@ std::vector> BuildSearchFilters( const std::string& model_name = "") { // Full parameter-driven filter sets (keep for easy rollback once catalog models are updated): - // std::vector> filter_sets; - // for (const auto& [device, eps] : ep_detector.GetAvailableDevicesToEPs()) { - // std::vector filters; - // - // std::vector deployment_options = model_filter; - // if (deployment_options.empty()) { - // deployment_options.push_back("foundryLocalDevices"); - // } - // - // filters.push_back(MakeFilter("DeploymentOptions", std::move(deployment_options))); - // if (!model_alias.empty()) { - // filters.push_back(MakeFilter("Alias", {model_alias})); - // } - // if (!model_name.empty()) { - // filters.push_back(MakeFilter("Name", {model_name})); - // } - // filters.push_back(MakeFilter("VariantInformation/VariantMetadata/Device", {ToLower(device)})); - // filters.push_back(MakeFilter("VariantInformation/VariantMetadata/ExecutionProvider", eps)); - // - // if (!latest_only) { - // // Placeholder to keep the parameter part of the behavior contract. - // // Asset-gallery query currently does not require an extra field to fetch all versions. - // } - // - // filter_sets.push_back(std::move(filters)); - // } - // return filter_sets; - - // Temporary CPU-only path for catalog validation. - (void)ep_detector; - (void)model_filter; - (void)latest_only; - (void)model_alias; - (void)model_name; - - std::vector filters; - filters.push_back(MakeFilter("VariantInformation/VariantMetadata/Device", {"cpu"})); - std::vector> filter_sets; - filter_sets.push_back(std::move(filters)); + for (const auto& [device, eps] : ep_detector.GetAvailableDevicesToEPs()) { + std::vector filters; + + std::vector deployment_options = model_filter; + // NOTE: The v2 catalog models do not yet have the "foundryLocalDevices" deployment option, + // so we don't add it here. Once the catalog models are updated, we can re-enable this default. + // if (deployment_options.empty()) { + // deployment_options.push_back("foundryLocalDevices"); + // } + + // filters.push_back(MakeFilter("DeploymentOptions", std::move(deployment_options))); + if (!model_alias.empty()) { + filters.push_back(MakeFilter("Alias", {model_alias})); + } + if (!model_name.empty()) { + filters.push_back(MakeFilter("Name", {model_name})); + } + filters.push_back(MakeFilter("VariantInformation/VariantMetadata/Device", {ToLower(device)})); + filters.push_back(MakeFilter("VariantInformation/VariantMetadata/ExecutionProvider", eps)); + + if (!latest_only) { + // Placeholder to keep the parameter part of the behavior contract. + // Asset-gallery query currently does not require an extra field to fetch all versions. + } + + filter_sets.push_back(std::move(filters)); + } return filter_sets; } From b77a1ef9801f9dc5cf64f13a88a177b229953906 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Thu, 16 Jul 2026 13:19:37 -0700 Subject: [PATCH 4/6] @self --- .pipelines/v2/templates/stages-cs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pipelines/v2/templates/stages-cs.yml b/.pipelines/v2/templates/stages-cs.yml index b83f48b35..b58549426 100644 --- a/.pipelines/v2/templates/stages-cs.yml +++ b/.pipelines/v2/templates/stages-cs.yml @@ -39,7 +39,7 @@ stages: steps: - checkout: self clean: true - - template: steps-build-cs.yml + - template: .pipelines/v2/templates/steps-build-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' outputDir: '$(Build.ArtifactStagingDirectory)/cs-sdk' @@ -68,7 +68,7 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-test-data-from-blob.yml@self - - template: steps-test-cs.yml + - template: .pipelines/v2/templates/steps-test-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' @@ -97,7 +97,7 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-test-data-from-blob.yml@self - - template: steps-test-cs.yml + - template: .pipelines/v2/templates/steps-test-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' @@ -125,7 +125,7 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-test-data-from-blob.yml@self - - template: steps-test-cs.yml + - template: .pipelines/v2/templates/steps-test-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' From c4c77c79dce68107898d1487807821d94c5a792d Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Thu, 16 Jul 2026 13:47:01 -0700 Subject: [PATCH 5/6] relative path --- .pipelines/v2/templates/stages-cs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pipelines/v2/templates/stages-cs.yml b/.pipelines/v2/templates/stages-cs.yml index b58549426..c0f2a31c5 100644 --- a/.pipelines/v2/templates/stages-cs.yml +++ b/.pipelines/v2/templates/stages-cs.yml @@ -39,7 +39,7 @@ stages: steps: - checkout: self clean: true - - template: .pipelines/v2/templates/steps-build-cs.yml@self + - template: steps-build-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' outputDir: '$(Build.ArtifactStagingDirectory)/cs-sdk' @@ -68,7 +68,7 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-test-data-from-blob.yml@self - - template: .pipelines/v2/templates/steps-test-cs.yml@self + - template: steps-test-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' @@ -97,7 +97,7 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-test-data-from-blob.yml@self - - template: .pipelines/v2/templates/steps-test-cs.yml@self + - template: steps-test-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' @@ -125,7 +125,7 @@ stages: - checkout: self clean: true - template: ../../templates/fetch-test-data-from-blob.yml@self - - template: .pipelines/v2/templates/steps-test-cs.yml@self + - template: steps-test-cs.yml@self parameters: flNugetDir: '$(Pipeline.Workspace)/cpp-nuget' testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' From 673286fea1c9484f504dd386f9b9e6e7bccbd879 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Thu, 16 Jul 2026 15:57:54 -0700 Subject: [PATCH 6/6] CatalogLiveTest.DumpLiveCatalog --- sdk_v2/cpp/test/sdk_api/catalog_live_test.cc | 2 +- sdk_v2/cpp/test/sdk_api/shared_test_env.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc b/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc index 559e14cd1..9ad867cea 100644 --- a/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc +++ b/sdk_v2/cpp/test/sdk_api/catalog_live_test.cc @@ -33,7 +33,7 @@ namespace fs = std::filesystem; // The live catalog endpoint and an explicit region. Setting the region exercises the public // Configuration::SetCatalogRegion() override path; it matches the URL template so routing stays // consistent. -constexpr const char* kLiveCatalogUrl = "https://ai.azure.com/api/centralus/ux/v1.0"; +constexpr const char* kLiveCatalogUrl = "https://api.catalog.azureml.ms/asset-gallery/v1.0/models"; constexpr const char* kLiveCatalogRegion = "centralus"; // A small model the repo already standardizes on (see SharedTestEnv). Used by the download test. diff --git a/sdk_v2/cpp/test/sdk_api/shared_test_env.h b/sdk_v2/cpp/test/sdk_api/shared_test_env.h index cc8e9ed71..02ebab5b2 100644 --- a/sdk_v2/cpp/test/sdk_api/shared_test_env.h +++ b/sdk_v2/cpp/test/sdk_api/shared_test_env.h @@ -278,7 +278,7 @@ class SharedTestEnv : public ::testing::Environment { // surface during test runs and end up in the rotating log file. config.SetDefaultLogLevel(FOUNDRY_LOCAL_LOG_INFO); - config.AddCatalogUrl("https://ai.azure.com/api/centralus/ux/v1.0"); + config.AddCatalogUrl("https://api.catalog.azureml.ms/asset-gallery/v1.0/models"); // Point the model cache at the shared test data directory when available. auto cache_dir = fl::test::SafeGetEnv("FOUNDRY_TEST_DATA_DIR");