From e7c16ae9de7bcdbffcd05b6b2315f5650cb790a9 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Mon, 29 Jun 2026 14:25:47 -0700 Subject: [PATCH 01/20] impl --- .../templates/fetch-test-data-from-blob.yml | 42 ++++++++++++++ .pipelines/v2/templates/stages-cs.yml | 23 ++------ .pipelines/v2/templates/stages-js.yml | 19 +------ .pipelines/v2/templates/stages-python.yml | 23 ++------ .pipelines/v2/templates/steps-build-linux.yml | 5 +- .pipelines/v2/templates/steps-build-macos.yml | 5 +- .../v2/templates/steps-build-windows.yml | 5 +- sdk_v2/DEVELOPMENT.md | 8 +++ .../cpp/test/internal_api/test_model_cache.h | 55 +++--------------- .../FoundryLocal.Tests/LOCAL_MODEL_TESTING.md | 23 ++------ .../Microsoft.AI.Foundry.Local.Tests.csproj | 9 --- sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs | 57 ++++--------------- .../FoundryLocal.Tests/appsettings.Test.json | 3 - sdk_v2/python/test/conftest.py | 13 +++-- 14 files changed, 101 insertions(+), 189 deletions(-) create mode 100644 .pipelines/templates/fetch-test-data-from-blob.yml delete mode 100644 sdk_v2/cs/test/FoundryLocal.Tests/appsettings.Test.json diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml new file mode 100644 index 000000000..5eba74e05 --- /dev/null +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -0,0 +1,42 @@ +# Download test model data from blob storage into a local directory. +# Uses azcopy with AZCLI auth so the agent identity can read from storage. + +parameters: +- name: destinationPath + type: string + default: '$(Build.SourcesDirectory)/test-data-shared' + +steps: +- task: AzureCLI@2 + displayName: 'Fetch test data from blob' + inputs: + azureSubscription: 'FoundryLocalCore-SP' + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $ErrorActionPreference = 'Stop' + + $destination = '${{ parameters.destinationPath }}' + $storageAccount = 'foundrylocalmodels' + $container = 'models' + $prefix = 'foundrylocal/models' + $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/*" + + if (Test-Path $destination) { + Remove-Item -Recurse -Force $destination + } + New-Item -ItemType Directory -Path $destination -Force | Out-Null + + azcopy copy $source $destination --recursive + if ($LASTEXITCODE -ne 0) { + throw "azcopy failed downloading test data from $source" + } + + $count = @(Get-ChildItem -Path $destination -Recurse -File).Count + if ($count -eq 0) { + throw "Downloaded test data is empty at $destination" + } + + Write-Host "Downloaded $count files into $destination" + env: + AZCOPY_AUTO_LOGIN_TYPE: AZCLI diff --git a/.pipelines/v2/templates/stages-cs.yml b/.pipelines/v2/templates/stages-cs.yml index c271dda13..4ba4ff7f3 100644 --- a/.pipelines/v2/templates/stages-cs.yml +++ b/.pipelines/v2/templates/stages-cs.yml @@ -129,9 +129,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-cs.yml parameters: flNugetDir: '$(Pipeline.Workspace)/${{ parameters._config_base.nativeArtifact }}' @@ -159,9 +157,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-cs.yml parameters: flNugetDir: '$(Pipeline.Workspace)/${{ parameters._config_winml.nativeArtifact }}' @@ -192,9 +188,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-cs.yml parameters: flNugetDir: '$(Pipeline.Workspace)/${{ parameters._config_base.nativeArtifact }}' @@ -223,16 +217,7 @@ stages: steps: - checkout: self clean: true - - bash: | - set -euo pipefail - if ! command -v git-lfs >/dev/null 2>&1; then - brew install git-lfs - fi - git lfs install - displayName: 'Install git-lfs (macOS)' - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-cs.yml parameters: flNugetDir: '$(Pipeline.Workspace)/${{ parameters._config_base.nativeArtifact }}' diff --git a/.pipelines/v2/templates/stages-js.yml b/.pipelines/v2/templates/stages-js.yml index e434f7c74..f11ed23bb 100644 --- a/.pipelines/v2/templates/stages-js.yml +++ b/.pipelines/v2/templates/stages-js.yml @@ -198,9 +198,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-js.yml parameters: rid: 'win-x64' @@ -224,9 +222,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-js.yml parameters: rid: 'linux-x64' @@ -252,16 +248,7 @@ stages: steps: - checkout: self clean: true - - bash: | - set -euo pipefail - if ! command -v git-lfs >/dev/null 2>&1; then - brew install git-lfs - fi - git lfs install - displayName: 'Install git-lfs (macOS)' - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-js.yml parameters: rid: 'osx-arm64' diff --git a/.pipelines/v2/templates/stages-python.yml b/.pipelines/v2/templates/stages-python.yml index 7a07c7dae..3f59570a1 100644 --- a/.pipelines/v2/templates/stages-python.yml +++ b/.pipelines/v2/templates/stages-python.yml @@ -178,9 +178,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-python.yml parameters: wheelDir: '$(Pipeline.Workspace)/python-sdk-base-win-x64' @@ -204,9 +202,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-python.yml parameters: wheelDir: '$(Pipeline.Workspace)/python-sdk-base-linux-x64' @@ -232,16 +228,7 @@ stages: steps: - checkout: self clean: true - - bash: | - set -euo pipefail - if ! command -v git-lfs >/dev/null 2>&1; then - brew install git-lfs - fi - git lfs install - displayName: 'Install git-lfs (macOS)' - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-python.yml parameters: wheelDir: '$(Pipeline.Workspace)/python-sdk-base-osx-arm64' @@ -336,9 +323,7 @@ stages: steps: - checkout: self clean: true - - template: ../../templates/checkout-steps.yml@self - parameters: - repoName: test-data-shared + - template: ../../templates/fetch-test-data-from-blob.yml@self - template: steps-test-python.yml parameters: wheelDir: '$(Pipeline.Workspace)/python-sdk-winml-win-x64' diff --git a/.pipelines/v2/templates/steps-build-linux.yml b/.pipelines/v2/templates/steps-build-linux.yml index b16b13fdd..f092abc41 100644 --- a/.pipelines/v2/templates/steps-build-linux.yml +++ b/.pipelines/v2/templates/steps-build-linux.yml @@ -43,10 +43,9 @@ steps: displayName: 'Append version define' - ${{ if eq(parameters.runTests, true) }}: - - template: ../../templates/checkout-steps.yml@self + - template: ../../templates/fetch-test-data-from-blob.yml@self parameters: - repoName: test-data-shared - basePath: '$(Agent.BuildDirectory)' + destinationPath: '$(Agent.BuildDirectory)/test-data-shared' - bash: | set -euo pipefail diff --git a/.pipelines/v2/templates/steps-build-macos.yml b/.pipelines/v2/templates/steps-build-macos.yml index 278bdbff8..92f9825e9 100644 --- a/.pipelines/v2/templates/steps-build-macos.yml +++ b/.pipelines/v2/templates/steps-build-macos.yml @@ -51,10 +51,9 @@ steps: displayName: 'Append version define' - ${{ if eq(parameters.runTests, true) }}: - - template: ../../templates/checkout-steps.yml@self + - template: ../../templates/fetch-test-data-from-blob.yml@self parameters: - repoName: test-data-shared - basePath: '$(Agent.BuildDirectory)' + destinationPath: '$(Agent.BuildDirectory)/test-data-shared' - bash: | set -euo pipefail diff --git a/.pipelines/v2/templates/steps-build-windows.yml b/.pipelines/v2/templates/steps-build-windows.yml index 0e17f07b6..dd1b8f5d5 100644 --- a/.pipelines/v2/templates/steps-build-windows.yml +++ b/.pipelines/v2/templates/steps-build-windows.yml @@ -78,10 +78,9 @@ steps: # Tests need shared model files. - ${{ if eq(parameters.runTests, true) }}: - - template: ../../templates/checkout-steps.yml@self + - template: ../../templates/fetch-test-data-from-blob.yml@self parameters: - repoName: test-data-shared - basePath: '$(Agent.BuildDirectory)' + destinationPath: '$(Agent.BuildDirectory)/test-data-shared' - ${{ if and(eq(parameters.arch, 'x64'), eq(parameters.useWinml, false)) }}: - script: >- diff --git a/sdk_v2/DEVELOPMENT.md b/sdk_v2/DEVELOPMENT.md index bc8afb6c2..31ab2fc1e 100644 --- a/sdk_v2/DEVELOPMENT.md +++ b/sdk_v2/DEVELOPMENT.md @@ -90,6 +90,14 @@ pwsh ./build_and_test_all.ps1 -ContinueOnError See `pwsh ./build_and_test_all.ps1 -?` for the full parameter list. +## Model cache for tests + +Model-dependent sdk_v2 tests require `FOUNDRY_TEST_DATA_DIR` to point to a +local model cache directory. + +In CI, sdk_v2 pipelines download model data from blob storage into this path. +For local runs, set it explicitly before invoking test commands. + ## Troubleshooting * **`cl.exe ... HostX86\x86\cl.exe` during pip install** — your shell has diff --git a/sdk_v2/cpp/test/internal_api/test_model_cache.h b/sdk_v2/cpp/test/internal_api/test_model_cache.h index 7f95e6bc0..b1b674440 100644 --- a/sdk_v2/cpp/test/internal_api/test_model_cache.h +++ b/sdk_v2/cpp/test/internal_api/test_model_cache.h @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // Shared helper for tests that need access to the shared test model cache. -// Mirrors the C# test setup pattern: -// - Default: look for "test-data-shared" in the parent of the git repo root -// - Override via FOUNDRY_TEST_DATA_DIR environment variable (absolute or relative path) +// Requires FOUNDRY_TEST_DATA_DIR to be set to an existing model cache path. #pragma once #include "utils/safe_getenv.h" @@ -17,56 +15,21 @@ namespace fs = std::filesystem; namespace fl::test { -/// Walk up from source_dir to find the directory containing .git. -inline fs::path FindRepoRoot(const fs::path& source_dir) { - fs::path dir = source_dir; - - while (!dir.empty() && dir.has_parent_path()) { - if (fs::exists(dir / ".git")) { - return dir; - } - - auto parent = dir.parent_path(); - if (parent == dir) { - break; - } - - dir = parent; - } - - throw std::runtime_error("Could not find git repository root from: " + source_dir.string()); -} - -/// Default test-data-shared directory name. -constexpr const char* kDefaultTestModelCacheDirName = "test-data-shared"; - /// Resolve the test model cache directory. -/// Priority: -/// 1. FOUNDRY_TEST_DATA_DIR environment variable (absolute or relative path) -/// 2. Default: {repo_root}/../test-data-shared/ +/// FOUNDRY_TEST_DATA_DIR is required. inline fs::path GetTestModelCacheDir() { - // Check environment variable first std::string env_value = SafeGetEnv("FOUNDRY_TEST_DATA_DIR"); - if (!env_value.empty()) { - fs::path env_path(env_value); - if (!fs::exists(env_path)) { - throw std::runtime_error("FOUNDRY_TEST_DATA_DIR does not exist: " + env_path.string()); - } - - return fs::canonical(env_path); + if (env_value.empty()) { + throw std::runtime_error( + "FOUNDRY_TEST_DATA_DIR is not set. Set it to a local model cache directory before running tests."); } - // Default: {repo_root}/../test-data-shared/ - fs::path repo_root = FindRepoRoot(fs::path(__FILE__).parent_path()); - fs::path default_path = repo_root.parent_path() / kDefaultTestModelCacheDirName; - - if (!fs::exists(default_path)) { - throw std::runtime_error( - "Test model cache directory not found at default location: " + default_path.string() + - "\nSet FOUNDRY_TEST_DATA_DIR environment variable to override."); + fs::path env_path(env_value); + if (!fs::exists(env_path)) { + throw std::runtime_error("FOUNDRY_TEST_DATA_DIR does not exist: " + env_path.string()); } - return fs::canonical(default_path); + return fs::canonical(env_path); } /// Returns true when running under a known CI provider. diff --git a/sdk_v2/cs/test/FoundryLocal.Tests/LOCAL_MODEL_TESTING.md b/sdk_v2/cs/test/FoundryLocal.Tests/LOCAL_MODEL_TESTING.md index 1b4a71e78..6f74cb537 100644 --- a/sdk_v2/cs/test/FoundryLocal.Tests/LOCAL_MODEL_TESTING.md +++ b/sdk_v2/cs/test/FoundryLocal.Tests/LOCAL_MODEL_TESTING.md @@ -1,24 +1,13 @@ # Running Local Model Tests -## Configuration - -The test model cache directory name is configured in `sdk/cs/test/FoundryLocal.Tests/appsettings.Test.json`: - -```json -{ - "TestModelCacheDirName": "test-data-shared" -} -``` - -If the value is a directory name it will be resolved as /../{TestModelCacheDirName}. -Otherwise the value will be resolved using Path.GetFullPath, which allows for absolute paths or -relative paths based on the current working directory. +Set `FOUNDRY_TEST_DATA_DIR` to a local model cache path before running tests. +The path can point to any directory layout that contains the models expected by +the test suite. ## Run the tests -The tests will automatically find the models in the configured test model cache directory. - ```bash -cd /path/to/parent-dir/foundry-local-sdk/sdk/cs/test/FoundryLocal.Tests -dotnet test Microsoft.AI.Foundry.Local.Tests.csproj --configuration Release# Running Local Model Tests +export FOUNDRY_TEST_DATA_DIR=/path/to/model-cache +cd /path/to/Foundry-Local/sdk_v2/cs/test/FoundryLocal.Tests +dotnet test Microsoft.AI.Foundry.Local.Tests.csproj --configuration Release ``` diff --git a/sdk_v2/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj b/sdk_v2/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj index 2f1e6f1a0..16e01ebb1 100644 --- a/sdk_v2/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj +++ b/sdk_v2/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj @@ -62,13 +62,6 @@ - - - - PreserveNewest - - - PreserveNewest @@ -87,8 +80,6 @@ all - - diff --git a/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs b/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs index e684e1e5d..cf5c5cf2d 100644 --- a/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs @@ -14,7 +14,6 @@ namespace Microsoft.AI.Foundry.Local.Tests; using System.Text.Json; using Microsoft.AI.Foundry.Local.Detail; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; #pragma warning disable CS0618 // Test helpers exercise PromptTemplate/ModelSettings which are obsolete but still supported. @@ -62,58 +61,24 @@ static Utils() ILogger logger = loggerFactory.CreateLogger("FoundryLocalSdkTest"); - // Read configuration from appsettings.Test.json - logger.LogDebug("Reading configuration from appsettings.Test.json"); - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.Test.json", optional: true, reloadOnChange: false) - .Build(); - - // Prefer the FOUNDRY_TEST_DATA_DIR env var when set (used by CI). If it points at - // an existing directory we treat it as an absolute path. Otherwise fall back to - // the appsettings.Test.json TestModelCacheDirName logic for inner-loop / VS use. + // FOUNDRY_TEST_DATA_DIR is required for model-dependent tests. var envCacheDir = Environment.GetEnvironmentVariable("FOUNDRY_TEST_DATA_DIR"); - string testDataSharedPath; - if (!string.IsNullOrWhiteSpace(envCacheDir) && Directory.Exists(envCacheDir)) + if (string.IsNullOrWhiteSpace(envCacheDir)) { - testDataSharedPath = Path.GetFullPath(envCacheDir); - logger.LogInformation( - "Using test model cache directory from FOUNDRY_TEST_DATA_DIR env var: {TestDataSharedPath}", - testDataSharedPath); + logger.LogWarning( + "FOUNDRY_TEST_DATA_DIR is not set. Integration tests will be skipped."); + Console.Error.WriteLine( + "[Utils::Utils] FOUNDRY_TEST_DATA_DIR is not set. Integration tests will be skipped."); + return; } - else - { - if (!string.IsNullOrWhiteSpace(envCacheDir)) - { - logger.LogWarning( - "FOUNDRY_TEST_DATA_DIR is set to '{EnvCacheDir}' but the directory does not exist; falling back to appsettings.Test.json.", - envCacheDir); - } - var testModelCacheDirName = configuration["TestModelCacheDirName"] ?? "test-data-shared"; - if (Path.IsPathRooted(testModelCacheDirName) || - testModelCacheDirName.Contains(Path.DirectorySeparatorChar) || - testModelCacheDirName.Contains(Path.AltDirectorySeparatorChar)) - { - // It's a relative or complete filepath, resolve from current directory - testDataSharedPath = Path.GetFullPath(testModelCacheDirName); - } - else - { - // It's just a directory name, combine with repo root parent - testDataSharedPath = Path.GetFullPath(Path.Combine(GetRepoRoot(), "..", testModelCacheDirName)); - } - - logger.LogInformation( - "Using test model cache directory from appsettings.Test.json: {TestDataSharedPath}", - testDataSharedPath); - } + string testDataSharedPath = Path.GetFullPath(envCacheDir); + logger.LogInformation( + "Using test model cache directory from FOUNDRY_TEST_DATA_DIR env var: {TestDataSharedPath}", + testDataSharedPath); if (!Directory.Exists(testDataSharedPath)) { - // Do NOT throw — that would abort every test in the assembly, including pure unit - // tests that don't touch the manager. Instead leave IntegrationTestsAvailable=false - // so integration tests skip via [SkipUnlessIntegration] while unit tests still run. logger.LogWarning( "Test model cache directory does not exist: {TestDataSharedPath}. Integration tests will be skipped. See LOCAL_MODEL_TESTING.md.", testDataSharedPath); diff --git a/sdk_v2/cs/test/FoundryLocal.Tests/appsettings.Test.json b/sdk_v2/cs/test/FoundryLocal.Tests/appsettings.Test.json deleted file mode 100644 index d42d87893..000000000 --- a/sdk_v2/cs/test/FoundryLocal.Tests/appsettings.Test.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "TestModelCacheDirName": "test-data-shared" -} diff --git a/sdk_v2/python/test/conftest.py b/sdk_v2/python/test/conftest.py index 5f521efed..a1fdccfce 100644 --- a/sdk_v2/python/test/conftest.py +++ b/sdk_v2/python/test/conftest.py @@ -38,9 +38,7 @@ def is_running_in_ci() -> bool: IS_CI: bool = is_running_in_ci() -# Optional override that points the SDK at a pre-staged model cache so -# integration tests can find cached models without downloading. -# Mirrors the C++ FOUNDRY_TEST_DATA_DIR env var. +# Required model cache path for sdk_v2 Python tests. FOUNDRY_TEST_DATA_DIR: str | None = os.environ.get("FOUNDRY_TEST_DATA_DIR") or None @@ -66,13 +64,18 @@ def manager(): # and must not close it on teardown. created_here = False + if not FOUNDRY_TEST_DATA_DIR: + pytest.skip("FOUNDRY_TEST_DATA_DIR is required for sdk_v2 Python tests.") + + if not os.path.isdir(FOUNDRY_TEST_DATA_DIR): + pytest.skip(f"FOUNDRY_TEST_DATA_DIR does not exist: {FOUNDRY_TEST_DATA_DIR}") + if FoundryLocalManager.instance is None: config_kwargs = { "app_name": "FoundryLocalPythonTests", "log_level": LogLevel.WARNING, + "model_cache_dir": FOUNDRY_TEST_DATA_DIR, } - if FOUNDRY_TEST_DATA_DIR: - config_kwargs["model_cache_dir"] = FOUNDRY_TEST_DATA_DIR config = Configuration(**config_kwargs) FoundryLocalManager.initialize(config) From 327fbe3e7c03f91d435d499d1a7b5188a26e9ea1 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Mon, 29 Jun 2026 15:00:25 -0700 Subject: [PATCH 02/20] diff azure sub --- .pipelines/templates/fetch-test-data-from-blob.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 5eba74e05..a987eedef 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -10,7 +10,7 @@ steps: - task: AzureCLI@2 displayName: 'Fetch test data from blob' inputs: - azureSubscription: 'FoundryLocalCore-SP' + azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime' scriptType: pscore scriptLocation: inlineScript inlineScript: | From 2a8f8d2645964749d225f9328c6ae03734ed3125 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Mon, 29 Jun 2026 15:13:15 -0700 Subject: [PATCH 03/20] tests --- .../templates/fetch-test-data-from-blob.yml | 38 +++++++++++++++---- .pipelines/v2/templates/steps-test-cs.yml | 4 +- .pipelines/v2/templates/steps-test-js.yml | 2 +- .pipelines/v2/templates/steps-test-python.yml | 4 +- sdk_v2/DEVELOPMENT.md | 10 +++++ 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index a987eedef..63fbaa591 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -20,16 +20,40 @@ steps: $storageAccount = 'foundrylocalmodels' $container = 'models' $prefix = 'foundrylocal/models' - $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/*" + New-Item -ItemType Directory -Path $destination -Force | Out-Null + + function Invoke-AzCopy { + param( + [string]$Source, + [string]$Target + ) + + $parent = Split-Path -Path $Target -Parent + if (-not [string]::IsNullOrEmpty($parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } - if (Test-Path $destination) { - Remove-Item -Recurse -Force $destination + $args = @('copy', $Source, $Target, '--recursive') + + & azcopy @args + if ($LASTEXITCODE -ne 0) { + throw "azcopy failed downloading test data from $Source" + } } - New-Item -ItemType Directory -Path $destination -Force | Out-Null - azcopy copy $source $destination --recursive - if ($LASTEXITCODE -ne 0) { - throw "azcopy failed downloading test data from $source" + $modelPaths = @( + 'qwen2.5-0.5b-instruct-generic-cpu-4', + 'openai-whisper-tiny-generic-cpu-4', + 'openai-whisper-tiny-generic-cpu-2', + 'nemotron-speech-streaming-en-0.6b-generic-cpu-3', + 'qwen3-embedding-0.6b-generic-cpu-1', + 'qwen3.5-0.8b-generic-cpu-2' + ) + + foreach ($relativePath in $modelPaths) { + $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/$relativePath/*" + $target = Join-Path $destination $relativePath + Invoke-AzCopy -Source $source -Target $target } $count = @(Get-ChildItem -Path $destination -Recurse -File).Count diff --git a/.pipelines/v2/templates/steps-test-cs.yml b/.pipelines/v2/templates/steps-test-cs.yml index 73274e275..d92b437df 100644 --- a/.pipelines/v2/templates/steps-test-cs.yml +++ b/.pipelines/v2/templates/steps-test-cs.yml @@ -2,7 +2,7 @@ # (FoundryLocal.Tests). The caller is responsible for: # - placing the Foundry Local Runtime nupkg in `flNugetDir` # - downloading the `version-info` pipeline artifact -# - checking out `test-data-shared` at `testDataSharedDir` +# - populating the model cache directory at `testDataSharedDir` parameters: - name: flNugetDir @@ -13,7 +13,7 @@ parameters: default: false - name: testDataSharedDir type: string - displayName: 'Absolute path to a checked-out test-data-shared working tree' + displayName: 'Absolute path to the model cache directory used by tests' - name: additionalTestArgs type: string default: '' diff --git a/.pipelines/v2/templates/steps-test-js.yml b/.pipelines/v2/templates/steps-test-js.yml index d161da06e..f33c69850 100644 --- a/.pipelines/v2/templates/steps-test-js.yml +++ b/.pipelines/v2/templates/steps-test-js.yml @@ -14,7 +14,7 @@ parameters: displayName: 'Path to the downloaded js-prebuild- artifact directory (root with prebuilds/ inside)' - name: testDataSharedDir type: string - displayName: 'Path to the checked-out test-data-shared repo' + displayName: 'Path to the model cache directory used by tests' steps: diff --git a/.pipelines/v2/templates/steps-test-python.yml b/.pipelines/v2/templates/steps-test-python.yml index 9396a7cce..63027b170 100644 --- a/.pipelines/v2/templates/steps-test-python.yml +++ b/.pipelines/v2/templates/steps-test-python.yml @@ -3,7 +3,7 @@ # # The caller is responsible for: # * placing the built wheel (.whl) under `wheelDir` -# * checking out test-data-shared at `testDataSharedDir` +# * populating the model cache directory at `testDataSharedDir` parameters: - name: wheelDir @@ -11,7 +11,7 @@ parameters: displayName: 'Path to directory containing the built foundry_local_sdk wheel' - name: testDataSharedDir type: string - displayName: 'Absolute path to a checked-out test-data-shared working tree' + displayName: 'Absolute path to the model cache directory used by tests' - name: isWinML type: boolean default: false diff --git a/sdk_v2/DEVELOPMENT.md b/sdk_v2/DEVELOPMENT.md index 31ab2fc1e..6e00decbf 100644 --- a/sdk_v2/DEVELOPMENT.md +++ b/sdk_v2/DEVELOPMENT.md @@ -98,6 +98,16 @@ local model cache directory. In CI, sdk_v2 pipelines download model data from blob storage into this path. For local runs, set it explicitly before invoking test commands. +Example: + +```powershell +$env:FOUNDRY_TEST_DATA_DIR = 'C:\path\to\model-cache' +pwsh ./build_and_test_all.ps1 +``` + +The directory can have any name. What matters is that it already contains the +model files expected by the tests you are running. + ## Troubleshooting * **`cl.exe ... HostX86\x86\cl.exe` during pip install** — your shell has From ce025b0af185eaba509076c60dc39c8af5c66e81 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Mon, 29 Jun 2026 16:29:47 -0700 Subject: [PATCH 04/20] Foundry Local PME --- .pipelines/templates/fetch-test-data-from-blob.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 63fbaa591..4d0b6aa95 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -10,7 +10,7 @@ steps: - task: AzureCLI@2 displayName: 'Fetch test data from blob' inputs: - azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime' + azureSubscription: 'Foundry Local PME' scriptType: pscore scriptLocation: inlineScript inlineScript: | From 4f56d23b2aabeddb9735810832dffda4f67ec0cc Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 08:26:59 -0700 Subject: [PATCH 05/20] add azure sub --- .pipelines/templates/fetch-test-data-from-blob.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 4d0b6aa95..63fbaa591 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -10,7 +10,7 @@ steps: - task: AzureCLI@2 displayName: 'Fetch test data from blob' inputs: - azureSubscription: 'Foundry Local PME' + azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime' scriptType: pscore scriptLocation: inlineScript inlineScript: | From 68e757cf63258aadd7caad244d87f9e6dfa1f6be Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 08:30:35 -0700 Subject: [PATCH 06/20] final azure sub --- .pipelines/templates/fetch-test-data-from-blob.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 63fbaa591..527d0b5fe 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -10,7 +10,7 @@ steps: - task: AzureCLI@2 displayName: 'Fetch test data from blob' inputs: - azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime' + azureSubscription: 'ortcibuild_readonly_mi2-ONNX Runtime-AIFoundryLocal' scriptType: pscore scriptLocation: inlineScript inlineScript: | From 7dae763eae64de9b9cf1ce592feca92a201bdf01 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 09:14:22 -0700 Subject: [PATCH 07/20] filepaths --- .pipelines/templates/fetch-test-data-from-blob.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 527d0b5fe..f7f6148b4 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -25,7 +25,8 @@ steps: function Invoke-AzCopy { param( [string]$Source, - [string]$Target + [string]$Target, + [string]$IncludePath ) $parent = Split-Path -Path $Target -Parent @@ -33,7 +34,7 @@ steps: New-Item -ItemType Directory -Path $parent -Force | Out-Null } - $args = @('copy', $Source, $Target, '--recursive') + $args = @('copy', $Source, $Target, '--include-path', $IncludePath, '--recursive') & azcopy @args if ($LASTEXITCODE -ne 0) { @@ -51,9 +52,8 @@ steps: ) foreach ($relativePath in $modelPaths) { - $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/$relativePath/*" - $target = Join-Path $destination $relativePath - Invoke-AzCopy -Source $source -Target $target + $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/*" + Invoke-AzCopy -Source $source -Target $destination -IncludePath $relativePath } $count = @(Get-ChildItem -Path $destination -Recurse -File).Count From a1500039abf29adefad870539cc1d4d8b89d0cec Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 11:57:13 -0700 Subject: [PATCH 08/20] hella logs --- .../templates/fetch-test-data-from-blob.yml | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index f7f6148b4..dba38d315 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -20,6 +20,23 @@ steps: $storageAccount = 'foundrylocalmodels' $container = 'models' $prefix = 'foundrylocal/models' + + Write-Host '=== Fetch test data from blob: diagnostics start ===' + Write-Host "PowerShell version: $($PSVersionTable.PSVersion)" + Write-Host "OS: $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)" + Write-Host "Destination path: $destination" + Write-Host "Storage account: $storageAccount" + Write-Host "Container: $container" + Write-Host "Prefix: $prefix" + + Write-Host 'Azure CLI version:' + az version + Write-Host 'AzCopy version:' + azcopy --version + + $accountContext = az account show --query "{name:name,id:id,tenantId:tenantId,userType:user.type}" -o json + Write-Host "Active Azure account context: $accountContext" + New-Item -ItemType Directory -Path $destination -Force | Out-Null function Invoke-AzCopy { @@ -42,6 +59,48 @@ steps: } } + function Get-FirstBlobMatch { + param( + [string]$BlobPrefix + ) + + $match = az storage blob list ` + --account-name $storageAccount ` + --container-name $container ` + --auth-mode login ` + --prefix $BlobPrefix ` + --num-results 1 ` + --query "[0].name" ` + -o tsv + + if ($LASTEXITCODE -ne 0) { + throw "Failed to probe blob prefix: $BlobPrefix" + } + + return $match + } + + function Get-BlobSamples { + param( + [string]$BlobPrefix + ) + + $samples = az storage blob list ` + --account-name $storageAccount ` + --container-name $container ` + --auth-mode login ` + --prefix $BlobPrefix ` + --num-results 5 ` + --query "[].name" ` + -o tsv + + if ($LASTEXITCODE -ne 0) { + throw "Failed to list blob samples for prefix: $BlobPrefix" + } + + return @($samples) + } + $modelPaths = @( 'qwen2.5-0.5b-instruct-generic-cpu-4', 'openai-whisper-tiny-generic-cpu-4', @@ -51,16 +110,79 @@ steps: 'qwen3.5-0.8b-generic-cpu-2' ) + $missingPaths = @() + $downloadedPaths = @() + foreach ($relativePath in $modelPaths) { + Write-Host "---- Evaluating model path: $relativePath" + $blobPrefix = "$prefix/$relativePath/" + Write-Host "Probing prefix: $blobPrefix" + + $match = Get-FirstBlobMatch -BlobPrefix $blobPrefix + if ([string]::IsNullOrWhiteSpace($match)) { + Write-Warning "No blobs found for prefix '$blobPrefix'" + + $sampleRoot = "$prefix/$relativePath" + $samples = Get-BlobSamples -BlobPrefix $sampleRoot + if ($samples.Count -gt 0) { + Write-Warning "Sample blobs under '$sampleRoot':" + foreach ($sample in $samples) { + Write-Warning " $sample" + } + } + + $missingPaths += $relativePath + continue + } + + Write-Host "First matching blob: $match" + $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/*" + Write-Host "AzCopy source: $source" + Write-Host "AzCopy include-path: $relativePath" + Write-Host "AzCopy target: $destination" + Invoke-AzCopy -Source $source -Target $destination -IncludePath $relativePath + + $localPath = Join-Path $destination $relativePath + $localCount = if (Test-Path -Path $localPath) { + @(Get-ChildItem -Path $localPath -Recurse -File).Count + } else { + 0 + } + Write-Host "Local file count for $relativePath: $localCount" + + if ($localCount -gt 0) { + $downloadedPaths += $relativePath + } + } + + if ($missingPaths.Count -gt 0) { + Write-Warning "Missing model paths in blob storage: $($missingPaths -join ', ')" + } + + if ($downloadedPaths.Count -gt 0) { + Write-Host "Downloaded model paths: $($downloadedPaths -join ', ')" + } else { + Write-Warning 'No model paths were downloaded successfully.' } $count = @(Get-ChildItem -Path $destination -Recurse -File).Count + Write-Host "Total downloaded file count under destination: $count" + + Write-Host 'Top-level destination entries:' + $topLevel = Get-ChildItem -Path $destination -Force + if ($topLevel.Count -gt 0) { + ($topLevel | Select-Object Mode, Name, Length | Format-Table -AutoSize | Out-String).TrimEnd() | Write-Host + } else { + Write-Host ' ' + } + if ($count -eq 0) { throw "Downloaded test data is empty at $destination" } Write-Host "Downloaded $count files into $destination" + Write-Host '=== Fetch test data from blob: diagnostics end ===' env: AZCOPY_AUTO_LOGIN_TYPE: AZCLI From 608ef789339768d7dc2913f93fba92b61bec893f Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 12:44:16 -0700 Subject: [PATCH 09/20] bug --- .pipelines/templates/fetch-test-data-from-blob.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index dba38d315..e12b71fd7 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -150,7 +150,7 @@ steps: } else { 0 } - Write-Host "Local file count for $relativePath: $localCount" + Write-Host "Local file count for ${relativePath}: $localCount" if ($localCount -gt 0) { $downloadedPaths += $relativePath From 35d10d39d915abefb29b5070a55ef5cebcde4652 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 13:40:56 -0700 Subject: [PATCH 10/20] pls --- .../templates/fetch-test-data-from-blob.yml | 109 +++--------------- 1 file changed, 13 insertions(+), 96 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index e12b71fd7..49b9ef2f7 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -21,29 +21,12 @@ steps: $container = 'models' $prefix = 'foundrylocal/models' - Write-Host '=== Fetch test data from blob: diagnostics start ===' - Write-Host "PowerShell version: $($PSVersionTable.PSVersion)" - Write-Host "OS: $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)" - Write-Host "Destination path: $destination" - Write-Host "Storage account: $storageAccount" - Write-Host "Container: $container" - Write-Host "Prefix: $prefix" - - Write-Host 'Azure CLI version:' - az version - Write-Host 'AzCopy version:' - azcopy --version - - $accountContext = az account show --query "{name:name,id:id,tenantId:tenantId,userType:user.type}" -o json - Write-Host "Active Azure account context: $accountContext" - New-Item -ItemType Directory -Path $destination -Force | Out-Null function Invoke-AzCopy { param( [string]$Source, - [string]$Target, - [string]$IncludePath + [string]$Target ) $parent = Split-Path -Path $Target -Parent @@ -51,7 +34,7 @@ steps: New-Item -ItemType Directory -Path $parent -Force | Out-Null } - $args = @('copy', $Source, $Target, '--include-path', $IncludePath, '--recursive') + $args = @('copy', $Source, $Target, '--recursive') & azcopy @args if ($LASTEXITCODE -ne 0) { @@ -80,109 +63,43 @@ steps: return $match } - function Get-BlobSamples { - param( - [string]$BlobPrefix - ) - - $samples = az storage blob list ` - --account-name $storageAccount ` - --container-name $container ` - --auth-mode login ` - --prefix $BlobPrefix ` - --num-results 5 ` - --query "[].name" ` - -o tsv - - if ($LASTEXITCODE -ne 0) { - throw "Failed to list blob samples for prefix: $BlobPrefix" - } - - return @($samples) - } - $modelPaths = @( - 'qwen2.5-0.5b-instruct-generic-cpu-4', - 'openai-whisper-tiny-generic-cpu-4', - 'openai-whisper-tiny-generic-cpu-2', - 'nemotron-speech-streaming-en-0.6b-generic-cpu-3', - 'qwen3-embedding-0.6b-generic-cpu-1', - 'qwen3.5-0.8b-generic-cpu-2' + 'qwen2.5-0.5b-instruct', + 'openai-whisper-tiny', + 'openai-whisper-tiny', + 'nemotron-speech-streaming-en-0.6b', + 'qwen3-embedding-0.6b', + 'qwen3.5-0.8b' ) $missingPaths = @() - $downloadedPaths = @() foreach ($relativePath in $modelPaths) { - Write-Host "---- Evaluating model path: $relativePath" $blobPrefix = "$prefix/$relativePath/" - Write-Host "Probing prefix: $blobPrefix" + $requestedUrl = "https://$storageAccount.blob.core.windows.net/$container/$blobPrefix*" + Write-Host "Requested blob URL: $requestedUrl" $match = Get-FirstBlobMatch -BlobPrefix $blobPrefix if ([string]::IsNullOrWhiteSpace($match)) { - Write-Warning "No blobs found for prefix '$blobPrefix'" - - $sampleRoot = "$prefix/$relativePath" - $samples = Get-BlobSamples -BlobPrefix $sampleRoot - if ($samples.Count -gt 0) { - Write-Warning "Sample blobs under '$sampleRoot':" - foreach ($sample in $samples) { - Write-Warning " $sample" - } - } - + Write-Warning "No blobs found for $requestedUrl" $missingPaths += $relativePath continue } - Write-Host "First matching blob: $match" - - $source = "https://$storageAccount.blob.core.windows.net/$container/$prefix/*" - Write-Host "AzCopy source: $source" - Write-Host "AzCopy include-path: $relativePath" - Write-Host "AzCopy target: $destination" - - Invoke-AzCopy -Source $source -Target $destination -IncludePath $relativePath - - $localPath = Join-Path $destination $relativePath - $localCount = if (Test-Path -Path $localPath) { - @(Get-ChildItem -Path $localPath -Recurse -File).Count - } else { - 0 - } - Write-Host "Local file count for ${relativePath}: $localCount" - - if ($localCount -gt 0) { - $downloadedPaths += $relativePath - } + $target = Join-Path $destination $relativePath + Invoke-AzCopy -Source $requestedUrl -Target $target } if ($missingPaths.Count -gt 0) { Write-Warning "Missing model paths in blob storage: $($missingPaths -join ', ')" } - if ($downloadedPaths.Count -gt 0) { - Write-Host "Downloaded model paths: $($downloadedPaths -join ', ')" - } else { - Write-Warning 'No model paths were downloaded successfully.' - } - $count = @(Get-ChildItem -Path $destination -Recurse -File).Count - Write-Host "Total downloaded file count under destination: $count" - - Write-Host 'Top-level destination entries:' - $topLevel = Get-ChildItem -Path $destination -Force - if ($topLevel.Count -gt 0) { - ($topLevel | Select-Object Mode, Name, Length | Format-Table -AutoSize | Out-String).TrimEnd() | Write-Host - } else { - Write-Host ' ' - } if ($count -eq 0) { throw "Downloaded test data is empty at $destination" } Write-Host "Downloaded $count files into $destination" - Write-Host '=== Fetch test data from blob: diagnostics end ===' env: AZCOPY_AUTO_LOGIN_TYPE: AZCLI From b460966d61e2b645df8e7239a5cf3ffa046b5791 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 13:45:20 -0700 Subject: [PATCH 11/20] simplify --- .../templates/fetch-test-data-from-blob.yml | 43 +++---------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 49b9ef2f7..a32686460 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -42,58 +42,22 @@ steps: } } - function Get-FirstBlobMatch { - param( - [string]$BlobPrefix - ) - - $match = az storage blob list ` - --account-name $storageAccount ` - --container-name $container ` - --auth-mode login ` - --prefix $BlobPrefix ` - --num-results 1 ` - --query "[0].name" ` - -o tsv - - if ($LASTEXITCODE -ne 0) { - throw "Failed to probe blob prefix: $BlobPrefix" - } - - return $match - } - $modelPaths = @( 'qwen2.5-0.5b-instruct', 'openai-whisper-tiny', - 'openai-whisper-tiny', 'nemotron-speech-streaming-en-0.6b', 'qwen3-embedding-0.6b', 'qwen3.5-0.8b' ) - $missingPaths = @() - foreach ($relativePath in $modelPaths) { - $blobPrefix = "$prefix/$relativePath/" - $requestedUrl = "https://$storageAccount.blob.core.windows.net/$container/$blobPrefix*" + $requestedUrl = "https://$storageAccount.blob.core.windows.net/$container/$prefix/$relativePath/*" Write-Host "Requested blob URL: $requestedUrl" - $match = Get-FirstBlobMatch -BlobPrefix $blobPrefix - if ([string]::IsNullOrWhiteSpace($match)) { - Write-Warning "No blobs found for $requestedUrl" - $missingPaths += $relativePath - continue - } - $target = Join-Path $destination $relativePath Invoke-AzCopy -Source $requestedUrl -Target $target } - if ($missingPaths.Count -gt 0) { - Write-Warning "Missing model paths in blob storage: $($missingPaths -join ', ')" - } - $count = @(Get-ChildItem -Path $destination -Recurse -File).Count if ($count -eq 0) { @@ -101,5 +65,10 @@ steps: } Write-Host "Downloaded $count files into $destination" + Write-Host "Cache directory contents for $destination:" + (Get-ChildItem -Path $destination -Recurse -Force | + Select-Object FullName, Length | + Format-Table -AutoSize | + Out-String).TrimEnd() | Write-Host env: AZCOPY_AUTO_LOGIN_TYPE: AZCLI From 2704f9a4c793e851f21612ff5ce513440bc622ad Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 14:08:34 -0700 Subject: [PATCH 12/20] bug fix --- .pipelines/templates/fetch-test-data-from-blob.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index a32686460..f857d51c2 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -65,7 +65,7 @@ steps: } Write-Host "Downloaded $count files into $destination" - Write-Host "Cache directory contents for $destination:" + Write-Host "Cache directory contents for $destination" (Get-ChildItem -Path $destination -Recurse -Force | Select-Object FullName, Length | Format-Table -AutoSize | From c2d6362ec95f4f066c77a8d8ec4ea1922fac5e67 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 15:05:25 -0700 Subject: [PATCH 13/20] map --- .../templates/fetch-test-data-from-blob.yml | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index a32686460..89ba2ab60 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -21,6 +21,22 @@ steps: $container = 'models' $prefix = 'foundrylocal/models' + if (-not (Get-Command azcopy -ErrorAction SilentlyContinue)) { + if ($IsMacOS) { + Write-Host 'azcopy not found on macOS agent. Installing via Homebrew...' + if (-not (Get-Command brew -ErrorAction SilentlyContinue)) { + throw 'Homebrew is required to install azcopy on macOS agents, but brew was not found.' + } + + brew update --quiet + brew install --quiet azcopy + } else { + throw 'azcopy is not installed on this agent.' + } + } + + azcopy --version + New-Item -ItemType Directory -Path $destination -Force | Out-Null function Invoke-AzCopy { @@ -42,19 +58,19 @@ steps: } } - $modelPaths = @( - 'qwen2.5-0.5b-instruct', - 'openai-whisper-tiny', - 'nemotron-speech-streaming-en-0.6b', - 'qwen3-embedding-0.6b', - 'qwen3.5-0.8b' + $modelMappings = @( + @{ SourcePath = 'qwen2.5-0.5b-instruct'; TargetAlias = 'qwen2.5-0.5b-instruct-generic-cpu-4' }, + @{ SourcePath = 'openai-whisper-tiny'; TargetAlias = 'openai-whisper-tiny-generic-cpu-4' }, + @{ SourcePath = 'nemotron-speech-streaming-en-0.6b'; TargetAlias = 'nemotron-speech-streaming-en-0.6b-generic-cpu-3' }, + @{ SourcePath = 'qwen3-embedding-0.6b'; TargetAlias = 'qwen3-embedding-0.6b-generic-cpu-1' }, + @{ SourcePath = 'qwen3.5-0.8b'; TargetAlias = 'qwen3.5-0.8b-generic-cpu-2' } ) - foreach ($relativePath in $modelPaths) { - $requestedUrl = "https://$storageAccount.blob.core.windows.net/$container/$prefix/$relativePath/*" + foreach ($model in $modelMappings) { + $requestedUrl = "https://$storageAccount.blob.core.windows.net/$container/$prefix/$($model.SourcePath)/onnx/cpu_and_mobile/*" Write-Host "Requested blob URL: $requestedUrl" - $target = Join-Path $destination $relativePath + $target = Join-Path $destination $model.TargetAlias Invoke-AzCopy -Source $requestedUrl -Target $target } From 4a260e94d1f3175ecb490387aacf9d2cfba28027 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Tue, 30 Jun 2026 16:19:16 -0700 Subject: [PATCH 14/20] versioned model names --- .../templates/fetch-test-data-from-blob.yml | 27 +++++++++++++++---- sdk_v2/js/test/_fixtures/realModelManager.ts | 4 +-- sdk_v2/js/test/audio-client.test.ts | 2 +- sdk_v2/js/test/audio-session.test.ts | 4 +-- sdk_v2/js/test/embedding-client.test.ts | 2 +- sdk_v2/js/test/embeddings-session.test.ts | 2 +- sdk_v2/js/test/live-audio-client.test.ts | 2 +- 7 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 9db799106..c917aced3 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -58,12 +58,29 @@ steps: } } + # The active SDK tests resolve versioned model aliases in cache. + # Keep one target alias per source model to avoid duplicate copies. $modelMappings = @( - @{ SourcePath = 'qwen2.5-0.5b-instruct'; TargetAlias = 'qwen2.5-0.5b-instruct-generic-cpu-4' }, - @{ SourcePath = 'openai-whisper-tiny'; TargetAlias = 'openai-whisper-tiny-generic-cpu-4' }, - @{ SourcePath = 'nemotron-speech-streaming-en-0.6b'; TargetAlias = 'nemotron-speech-streaming-en-0.6b-generic-cpu-3' }, - @{ SourcePath = 'qwen3-embedding-0.6b'; TargetAlias = 'qwen3-embedding-0.6b-generic-cpu-1' }, - @{ SourcePath = 'qwen3.5-0.8b'; TargetAlias = 'qwen3.5-0.8b-generic-cpu-2' } + @{ + SourcePath = 'qwen2.5-0.5b-instruct' + TargetAlias = 'qwen2.5-0.5b-instruct-generic-cpu-4' + }, + @{ + SourcePath = 'openai-whisper-tiny' + TargetAlias = 'openai-whisper-tiny-generic-cpu-4' + }, + @{ + SourcePath = 'nemotron-speech-streaming-en-0.6b' + TargetAlias = 'nemotron-speech-streaming-en-0.6b-generic-cpu-3' + }, + @{ + SourcePath = 'qwen3-embedding-0.6b' + TargetAlias = 'qwen3-embedding-0.6b-generic-cpu-1' + }, + @{ + SourcePath = 'qwen3.5-0.8b' + TargetAlias = 'qwen3.5-0.8b-generic-cpu-2' + } ) foreach ($model in $modelMappings) { diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index 786bf427e..d752a73ad 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -43,7 +43,7 @@ export interface RealModelManagerOptions { /** * Preferred model alias / name. If supplied AND the model is in the * catalog, it wins over the smallest-by-task fallback. Defaults to - * "qwen2.5-0.5b" — alias of the smallest chat model we ship. + * a versioned chat alias that we pre-cache in CI. */ readonly namePreference?: string; } @@ -74,7 +74,7 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): modelCacheDir: envCache, }); const catalog = manager.catalog; - const namePref = opts.namePreference ?? "qwen2.5-0.5b"; + const namePref = opts.namePreference ?? "qwen2.5-0.5b-instruct-generic-cpu-4"; // Preference 1: exact name / alias hit (V1 throws on miss, so swallow). let model: IModel | undefined; diff --git a/sdk_v2/js/test/audio-client.test.ts b/sdk_v2/js/test/audio-client.test.ts index 0cf818311..18de8bf3b 100644 --- a/sdk_v2/js/test/audio-client.test.ts +++ b/sdk_v2/js/test/audio-client.test.ts @@ -51,7 +51,7 @@ describe.skipIf(!haveTestModelCache)("AudioClient (real whisper-tiny model, V1 O beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "whisper-tiny", + namePreference: "openai-whisper-tiny-generic-cpu-4", task: "automatic-speech-recognition", }); if (fixture !== undefined) { diff --git a/sdk_v2/js/test/audio-session.test.ts b/sdk_v2/js/test/audio-session.test.ts index a2a7fcb7d..f0f79ebee 100644 --- a/sdk_v2/js/test/audio-session.test.ts +++ b/sdk_v2/js/test/audio-session.test.ts @@ -257,7 +257,7 @@ describe.skipIf(!haveTestModelCache)("AudioSession (real whisper-tiny model)", ( beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "whisper-tiny", + namePreference: "openai-whisper-tiny-generic-cpu-4", task: "automatic-speech-recognition", }); }, 5 * 60_000); @@ -394,7 +394,7 @@ describe.skipIf(!haveTestModelCache)("AudioSession (real nemotron streaming mode beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu", + namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu-3", task: "automatic-speech-recognition", }); }, 5 * 60_000); diff --git a/sdk_v2/js/test/embedding-client.test.ts b/sdk_v2/js/test/embedding-client.test.ts index b9076b93a..032a87d51 100644 --- a/sdk_v2/js/test/embedding-client.test.ts +++ b/sdk_v2/js/test/embedding-client.test.ts @@ -48,7 +48,7 @@ describe.skipIf(!haveTestModelCache)("EmbeddingClient (real model, V1 OpenAI-JSO beforeAll(async () => { fixture = await setupRealModelManager({ task: "embeddings", - namePreference: "qwen3-embedding-0.6b-generic-cpu", + namePreference: "qwen3-embedding-0.6b-generic-cpu-1", }); if (fixture !== undefined) { client = fixture.model.createEmbeddingClient(); diff --git a/sdk_v2/js/test/embeddings-session.test.ts b/sdk_v2/js/test/embeddings-session.test.ts index 341f0dc52..ae87c69da 100644 --- a/sdk_v2/js/test/embeddings-session.test.ts +++ b/sdk_v2/js/test/embeddings-session.test.ts @@ -59,7 +59,7 @@ describe.skipIf(!haveTestModelCache)("EmbeddingsSession (real model)", () => { beforeAll(async () => { fixture = await setupRealModelManager({ task: "embeddings", - namePreference: "qwen3-embedding-0.6b-generic-cpu", + namePreference: "qwen3-embedding-0.6b-generic-cpu-1", }); }, 5 * 60_000); diff --git a/sdk_v2/js/test/live-audio-client.test.ts b/sdk_v2/js/test/live-audio-client.test.ts index d3c1732c0..0e79403ed 100644 --- a/sdk_v2/js/test/live-audio-client.test.ts +++ b/sdk_v2/js/test/live-audio-client.test.ts @@ -54,7 +54,7 @@ describe.skipIf(!haveTestModelCache)("LiveAudioTranscriptionSession (V1, real ne beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu", + namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu-3", task: "automatic-speech-recognition", }); if (fixture !== undefined) { From f90d0107425ae48bcd4a60389b37171270700b28 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Wed, 1 Jul 2026 08:44:12 -0700 Subject: [PATCH 15/20] /Microsoft --- .../templates/fetch-test-data-from-blob.yml | 12 +++++++++++- sdk_v2/cpp/test/internal_api/test_model_cache.h | 8 +++++++- sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs | 6 +++--- sdk_v2/js/test/_fixtures/realModelManager.ts | 16 +++++++++------- sdk_v2/js/test/audio-session.test.ts | 2 +- sdk_v2/js/test/live-audio-client.test.ts | 2 +- 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index c917aced3..115c6709e 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -38,6 +38,8 @@ steps: azcopy --version New-Item -ItemType Directory -Path $destination -Force | Out-Null + $publisherRoot = Join-Path $destination 'Microsoft' + New-Item -ItemType Directory -Path $publisherRoot -Force | Out-Null function Invoke-AzCopy { param( @@ -80,6 +82,14 @@ steps: @{ SourcePath = 'qwen3.5-0.8b' TargetAlias = 'qwen3.5-0.8b-generic-cpu-2' + }, + @{ + SourcePath = 'nemotron-3.5-asr-streaming-0.6b' + TargetAlias = 'nemotron-3.5-asr-streaming-0.6b-generic-cpu-3' + }, + @{ + SourcePath = 'deepseek-r1-distill-qwen-14b' + TargetAlias = 'deepseek-r1-distill-qwen-14b-generic-cpu-4' } ) @@ -87,7 +97,7 @@ steps: $requestedUrl = "https://$storageAccount.blob.core.windows.net/$container/$prefix/$($model.SourcePath)/onnx/cpu_and_mobile/*" Write-Host "Requested blob URL: $requestedUrl" - $target = Join-Path $destination $model.TargetAlias + $target = Join-Path $publisherRoot $model.TargetAlias Invoke-AzCopy -Source $requestedUrl -Target $target } diff --git a/sdk_v2/cpp/test/internal_api/test_model_cache.h b/sdk_v2/cpp/test/internal_api/test_model_cache.h index b1b674440..ea08b9ba3 100644 --- a/sdk_v2/cpp/test/internal_api/test_model_cache.h +++ b/sdk_v2/cpp/test/internal_api/test_model_cache.h @@ -29,7 +29,13 @@ inline fs::path GetTestModelCacheDir() { throw std::runtime_error("FOUNDRY_TEST_DATA_DIR does not exist: " + env_path.string()); } - return fs::canonical(env_path); + fs::path publisher_path = env_path / "Microsoft"; + if (!fs::exists(publisher_path)) { + throw std::runtime_error("FOUNDRY_TEST_DATA_DIR/Microsoft does not exist: " + + publisher_path.string()); + } + + return fs::canonical(publisher_path); } /// Returns true when running under a known CI provider. diff --git a/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs b/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs index cf5c5cf2d..f05fd8f31 100644 --- a/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk_v2/cs/test/FoundryLocal.Tests/Utils.cs @@ -72,9 +72,9 @@ static Utils() return; } - string testDataSharedPath = Path.GetFullPath(envCacheDir); + string testDataSharedPath = Path.GetFullPath(Path.Combine(envCacheDir, "Microsoft")); logger.LogInformation( - "Using test model cache directory from FOUNDRY_TEST_DATA_DIR env var: {TestDataSharedPath}", + "Using test model cache directory from FOUNDRY_TEST_DATA_DIR/Microsoft: {TestDataSharedPath}", testDataSharedPath); if (!Directory.Exists(testDataSharedPath)) @@ -92,7 +92,7 @@ static Utils() // exactly which path we resolved. Critical when diagnosing initialization // failures from CI logs only. Console.WriteLine($"[Utils::Utils] FOUNDRY_TEST_DATA_DIR env: '{envCacheDir}'"); - Console.WriteLine($"[Utils::Utils] Resolved test model cache: '{testDataSharedPath}'"); + Console.WriteLine($"[Utils::Utils] Resolved test model cache (FOUNDRY_TEST_DATA_DIR/Microsoft): '{testDataSharedPath}'"); var config = new Configuration { diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index d752a73ad..dcb30c118 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -11,14 +11,16 @@ // a multi-gigabyte download. Local devs implicitly opt into downloads simply // by setting FOUNDRY_TEST_DATA_DIR. import { existsSync, statSync } from "node:fs"; +import { join } from "node:path"; import type { Catalog } from "../../src/catalog.js"; import { FoundryLocalManager } from "../../src/foundryLocalManager.js"; import type { IModel } from "../../src/imodel.js"; const envCache = process.env.FOUNDRY_TEST_DATA_DIR; +const publisherCache = envCache !== undefined && envCache.length > 0 ? join(envCache, "Microsoft") : undefined; const cacheDirExists = - envCache !== undefined && envCache.length > 0 && existsSync(envCache) && statSync(envCache).isDirectory(); + publisherCache !== undefined && existsSync(publisherCache) && statSync(publisherCache).isDirectory(); /** * True iff `FOUNDRY_TEST_DATA_DIR` is set and points at a real directory. @@ -27,8 +29,8 @@ const cacheDirExists = export const haveTestModelCache: boolean = cacheDirExists; export const testModelCacheDiagnostic = haveTestModelCache - ? `[v2 SDK real-model tests] using cache dir ${envCache}` - : "[v2 SDK real-model tests] SKIPPED — FOUNDRY_TEST_DATA_DIR is not set or does not exist"; + ? `[v2 SDK real-model tests] using cache dir ${publisherCache}` + : "[v2 SDK real-model tests] SKIPPED — FOUNDRY_TEST_DATA_DIR/Microsoft is not set or does not exist"; const isCi: boolean = process.env.CI !== undefined || process.env.TF_BUILD !== undefined; @@ -63,15 +65,15 @@ export interface RealModelManagerFixture { * must gate the `describe` block themselves. */ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): Promise { - if (!haveTestModelCache || envCache === undefined) { + if (!haveTestModelCache || publisherCache === undefined) { throw new Error( - "setupRealModelManager called without FOUNDRY_TEST_DATA_DIR — gate the describe with `skipIf(!haveTestModelCache)`", + "setupRealModelManager called without FOUNDRY_TEST_DATA_DIR/Microsoft — gate the describe with `skipIf(!haveTestModelCache)`", ); } const manager = FoundryLocalManager.create({ appName: opts.appName ?? "foundry-local-js-sdk-v2-real-tests", - modelCacheDir: envCache, + modelCacheDir: publisherCache, }); const catalog = manager.catalog; const namePref = opts.namePreference ?? "qwen2.5-0.5b-instruct-generic-cpu-4"; @@ -85,7 +87,7 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): } // Preference 1b: catalog entries often carry a `-N` version suffix - // (e.g. `nemotron-speech-streaming-en-0.6b-generic-cpu-3`). If the exact + // (e.g. `nemotron-3.5-asr-streaming-0.6b-generic-cpu-3`). If the exact // hit missed, try matching by prefix before falling back to the task // filter — caller-specified names should win over "smallest by task". if (model === undefined && opts.namePreference !== undefined) { diff --git a/sdk_v2/js/test/audio-session.test.ts b/sdk_v2/js/test/audio-session.test.ts index f0f79ebee..c1dde6b4e 100644 --- a/sdk_v2/js/test/audio-session.test.ts +++ b/sdk_v2/js/test/audio-session.test.ts @@ -394,7 +394,7 @@ describe.skipIf(!haveTestModelCache)("AudioSession (real nemotron streaming mode beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu-3", + namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu-3", task: "automatic-speech-recognition", }); }, 5 * 60_000); diff --git a/sdk_v2/js/test/live-audio-client.test.ts b/sdk_v2/js/test/live-audio-client.test.ts index 0e79403ed..f9920f32f 100644 --- a/sdk_v2/js/test/live-audio-client.test.ts +++ b/sdk_v2/js/test/live-audio-client.test.ts @@ -54,7 +54,7 @@ describe.skipIf(!haveTestModelCache)("LiveAudioTranscriptionSession (V1, real ne beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu-3", + namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu-3", task: "automatic-speech-recognition", }); if (fixture !== undefined) { From cf1802eafee8ce787c2400aa8c22d4bc0908e3f0 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Wed, 1 Jul 2026 09:53:23 -0700 Subject: [PATCH 16/20] revert --- sdk_v2/js/test/_fixtures/realModelManager.ts | 88 ++++++++++++++------ sdk_v2/js/test/audio-client.test.ts | 2 +- sdk_v2/js/test/audio-session.test.ts | 4 +- sdk_v2/js/test/embedding-client.test.ts | 2 +- sdk_v2/js/test/embeddings-session.test.ts | 2 +- sdk_v2/js/test/live-audio-client.test.ts | 2 +- 6 files changed, 67 insertions(+), 33 deletions(-) diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index dcb30c118..0779fd721 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -19,8 +19,7 @@ import type { IModel } from "../../src/imodel.js"; const envCache = process.env.FOUNDRY_TEST_DATA_DIR; const publisherCache = envCache !== undefined && envCache.length > 0 ? join(envCache, "Microsoft") : undefined; -const cacheDirExists = - publisherCache !== undefined && existsSync(publisherCache) && statSync(publisherCache).isDirectory(); +const cacheDirExists = publisherCache !== undefined && existsSync(publisherCache) && statSync(publisherCache).isDirectory(); /** * True iff `FOUNDRY_TEST_DATA_DIR` is set and points at a real directory. @@ -34,6 +33,31 @@ export const testModelCacheDiagnostic = haveTestModelCache const isCi: boolean = process.env.CI !== undefined || process.env.TF_BUILD !== undefined; +function normalizeModelLabel(value: string): string { + return value.trim().toLowerCase().replace(/-\d+$/, ""); +} + +function pickBestModel(candidates: readonly IModel[]): IModel | undefined { + if (candidates.length === 0) { + return undefined; + } + + const sorted = [...candidates]; + sorted.sort((a, b) => { + if (a.isCached !== b.isCached) { + return a.isCached ? -1 : 1; + } + const aSize = a.info.fileSizeMb ?? Number.POSITIVE_INFINITY; + const bSize = b.info.fileSizeMb ?? Number.POSITIVE_INFINITY; + if (aSize !== bSize) { + return aSize - bSize; + } + return a.info.name.localeCompare(b.info.name); + }); + + return sorted[0]; +} + export interface RealModelManagerOptions { /** Override the appName. Defaults to a fixed test id. */ readonly appName?: string; @@ -76,33 +100,47 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): modelCacheDir: publisherCache, }); const catalog = manager.catalog; - const namePref = opts.namePreference ?? "qwen2.5-0.5b-instruct-generic-cpu-4"; - - // Preference 1: exact name / alias hit (V1 throws on miss, so swallow). - let model: IModel | undefined; - try { - model = await catalog.getModel(namePref); - } catch { - model = undefined; + const cachedModels = await catalog.getCachedModels(); + const allModels = await catalog.getModels(); + const namePref = opts.namePreference ?? "qwen2.5-0.5b-instruct-generic-cpu"; + + // Normalize catalog labels from cache by stripping trailing version suffix. + const normalizedPref = normalizeModelLabel(namePref); + let model = pickBestModel( + cachedModels.filter((m) => { + const normalizedName = normalizeModelLabel(m.info.name); + const normalizedAlias = normalizeModelLabel(m.info.alias); + return normalizedName === normalizedPref || normalizedAlias === normalizedPref; + }), + ); + + // Fallback for local runs where cache may be empty/incomplete. + if (model === undefined) { + model = pickBestModel( + allModels.filter((m) => { + const normalizedName = normalizeModelLabel(m.info.name); + const normalizedAlias = normalizeModelLabel(m.info.alias); + return normalizedName === normalizedPref || normalizedAlias === normalizedPref; + }), + ); } - // Preference 1b: catalog entries often carry a `-N` version suffix - // (e.g. `nemotron-3.5-asr-streaming-0.6b-generic-cpu-3`). If the exact - // hit missed, try matching by prefix before falling back to the task - // filter — caller-specified names should win over "smallest by task". - if (model === undefined && opts.namePreference !== undefined) { - const all = await catalog.getModels(); - const prefixed = all.find((m) => m.info.name.startsWith(namePref)); - if (prefixed !== undefined) { - model = prefixed; + // Preference 2: smallest model matching the task filter. + if (model === undefined) { + const task = opts.task ?? "chat-completion"; + const matching = cachedModels.filter((m) => { + const info = m.info; + return info.task === task && info.deviceType === "CPU"; + }); + const fromCache = pickBestModel(matching); + if (fromCache !== undefined) { + model = fromCache; } } - // Preference 2: smallest model matching the task filter. if (model === undefined) { const task = opts.task ?? "chat-completion"; - const all = await catalog.getModels(); - const matching = all.filter((m) => { + const matching = allModels.filter((m) => { const info = m.info; return info.task === task && info.deviceType === "CPU"; }); @@ -112,11 +150,7 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`, ); } - matching.sort( - (a, b) => - (a.info.filesizeMb ?? Number.POSITIVE_INFINITY) - (b.info.filesizeMb ?? Number.POSITIVE_INFINITY), - ); - model = matching[0]; + model = pickBestModel(matching); } if (model === undefined) { manager.dispose(); diff --git a/sdk_v2/js/test/audio-client.test.ts b/sdk_v2/js/test/audio-client.test.ts index 18de8bf3b..6b3eb9d3c 100644 --- a/sdk_v2/js/test/audio-client.test.ts +++ b/sdk_v2/js/test/audio-client.test.ts @@ -51,7 +51,7 @@ describe.skipIf(!haveTestModelCache)("AudioClient (real whisper-tiny model, V1 O beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "openai-whisper-tiny-generic-cpu-4", + namePreference: "openai-whisper-tiny-generic-cpu", task: "automatic-speech-recognition", }); if (fixture !== undefined) { diff --git a/sdk_v2/js/test/audio-session.test.ts b/sdk_v2/js/test/audio-session.test.ts index c1dde6b4e..621f407d5 100644 --- a/sdk_v2/js/test/audio-session.test.ts +++ b/sdk_v2/js/test/audio-session.test.ts @@ -257,7 +257,7 @@ describe.skipIf(!haveTestModelCache)("AudioSession (real whisper-tiny model)", ( beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "openai-whisper-tiny-generic-cpu-4", + namePreference: "openai-whisper-tiny-generic-cpu", task: "automatic-speech-recognition", }); }, 5 * 60_000); @@ -394,7 +394,7 @@ describe.skipIf(!haveTestModelCache)("AudioSession (real nemotron streaming mode beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu-3", + namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu", task: "automatic-speech-recognition", }); }, 5 * 60_000); diff --git a/sdk_v2/js/test/embedding-client.test.ts b/sdk_v2/js/test/embedding-client.test.ts index 032a87d51..b9076b93a 100644 --- a/sdk_v2/js/test/embedding-client.test.ts +++ b/sdk_v2/js/test/embedding-client.test.ts @@ -48,7 +48,7 @@ describe.skipIf(!haveTestModelCache)("EmbeddingClient (real model, V1 OpenAI-JSO beforeAll(async () => { fixture = await setupRealModelManager({ task: "embeddings", - namePreference: "qwen3-embedding-0.6b-generic-cpu-1", + namePreference: "qwen3-embedding-0.6b-generic-cpu", }); if (fixture !== undefined) { client = fixture.model.createEmbeddingClient(); diff --git a/sdk_v2/js/test/embeddings-session.test.ts b/sdk_v2/js/test/embeddings-session.test.ts index ae87c69da..341f0dc52 100644 --- a/sdk_v2/js/test/embeddings-session.test.ts +++ b/sdk_v2/js/test/embeddings-session.test.ts @@ -59,7 +59,7 @@ describe.skipIf(!haveTestModelCache)("EmbeddingsSession (real model)", () => { beforeAll(async () => { fixture = await setupRealModelManager({ task: "embeddings", - namePreference: "qwen3-embedding-0.6b-generic-cpu-1", + namePreference: "qwen3-embedding-0.6b-generic-cpu", }); }, 5 * 60_000); diff --git a/sdk_v2/js/test/live-audio-client.test.ts b/sdk_v2/js/test/live-audio-client.test.ts index f9920f32f..2d6925852 100644 --- a/sdk_v2/js/test/live-audio-client.test.ts +++ b/sdk_v2/js/test/live-audio-client.test.ts @@ -54,7 +54,7 @@ describe.skipIf(!haveTestModelCache)("LiveAudioTranscriptionSession (V1, real ne beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu-3", + namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu", task: "automatic-speech-recognition", }); if (fixture !== undefined) { From 5175a472f6ad160656cbec605ed16247f770d2e0 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Wed, 1 Jul 2026 10:54:20 -0700 Subject: [PATCH 17/20] reset --- sdk_v2/js/test/_fixtures/realModelManager.ts | 102 ++++++------------- 1 file changed, 33 insertions(+), 69 deletions(-) diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index 0779fd721..786bf427e 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -11,15 +11,14 @@ // a multi-gigabyte download. Local devs implicitly opt into downloads simply // by setting FOUNDRY_TEST_DATA_DIR. import { existsSync, statSync } from "node:fs"; -import { join } from "node:path"; import type { Catalog } from "../../src/catalog.js"; import { FoundryLocalManager } from "../../src/foundryLocalManager.js"; import type { IModel } from "../../src/imodel.js"; const envCache = process.env.FOUNDRY_TEST_DATA_DIR; -const publisherCache = envCache !== undefined && envCache.length > 0 ? join(envCache, "Microsoft") : undefined; -const cacheDirExists = publisherCache !== undefined && existsSync(publisherCache) && statSync(publisherCache).isDirectory(); +const cacheDirExists = + envCache !== undefined && envCache.length > 0 && existsSync(envCache) && statSync(envCache).isDirectory(); /** * True iff `FOUNDRY_TEST_DATA_DIR` is set and points at a real directory. @@ -28,36 +27,11 @@ const cacheDirExists = publisherCache !== undefined && existsSync(publisherCache export const haveTestModelCache: boolean = cacheDirExists; export const testModelCacheDiagnostic = haveTestModelCache - ? `[v2 SDK real-model tests] using cache dir ${publisherCache}` - : "[v2 SDK real-model tests] SKIPPED — FOUNDRY_TEST_DATA_DIR/Microsoft is not set or does not exist"; + ? `[v2 SDK real-model tests] using cache dir ${envCache}` + : "[v2 SDK real-model tests] SKIPPED — FOUNDRY_TEST_DATA_DIR is not set or does not exist"; const isCi: boolean = process.env.CI !== undefined || process.env.TF_BUILD !== undefined; -function normalizeModelLabel(value: string): string { - return value.trim().toLowerCase().replace(/-\d+$/, ""); -} - -function pickBestModel(candidates: readonly IModel[]): IModel | undefined { - if (candidates.length === 0) { - return undefined; - } - - const sorted = [...candidates]; - sorted.sort((a, b) => { - if (a.isCached !== b.isCached) { - return a.isCached ? -1 : 1; - } - const aSize = a.info.fileSizeMb ?? Number.POSITIVE_INFINITY; - const bSize = b.info.fileSizeMb ?? Number.POSITIVE_INFINITY; - if (aSize !== bSize) { - return aSize - bSize; - } - return a.info.name.localeCompare(b.info.name); - }); - - return sorted[0]; -} - export interface RealModelManagerOptions { /** Override the appName. Defaults to a fixed test id. */ readonly appName?: string; @@ -69,7 +43,7 @@ export interface RealModelManagerOptions { /** * Preferred model alias / name. If supplied AND the model is in the * catalog, it wins over the smallest-by-task fallback. Defaults to - * a versioned chat alias that we pre-cache in CI. + * "qwen2.5-0.5b" — alias of the smallest chat model we ship. */ readonly namePreference?: string; } @@ -89,58 +63,44 @@ export interface RealModelManagerFixture { * must gate the `describe` block themselves. */ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): Promise { - if (!haveTestModelCache || publisherCache === undefined) { + if (!haveTestModelCache || envCache === undefined) { throw new Error( - "setupRealModelManager called without FOUNDRY_TEST_DATA_DIR/Microsoft — gate the describe with `skipIf(!haveTestModelCache)`", + "setupRealModelManager called without FOUNDRY_TEST_DATA_DIR — gate the describe with `skipIf(!haveTestModelCache)`", ); } const manager = FoundryLocalManager.create({ appName: opts.appName ?? "foundry-local-js-sdk-v2-real-tests", - modelCacheDir: publisherCache, + modelCacheDir: envCache, }); const catalog = manager.catalog; - const cachedModels = await catalog.getCachedModels(); - const allModels = await catalog.getModels(); - const namePref = opts.namePreference ?? "qwen2.5-0.5b-instruct-generic-cpu"; - - // Normalize catalog labels from cache by stripping trailing version suffix. - const normalizedPref = normalizeModelLabel(namePref); - let model = pickBestModel( - cachedModels.filter((m) => { - const normalizedName = normalizeModelLabel(m.info.name); - const normalizedAlias = normalizeModelLabel(m.info.alias); - return normalizedName === normalizedPref || normalizedAlias === normalizedPref; - }), - ); - - // Fallback for local runs where cache may be empty/incomplete. - if (model === undefined) { - model = pickBestModel( - allModels.filter((m) => { - const normalizedName = normalizeModelLabel(m.info.name); - const normalizedAlias = normalizeModelLabel(m.info.alias); - return normalizedName === normalizedPref || normalizedAlias === normalizedPref; - }), - ); + const namePref = opts.namePreference ?? "qwen2.5-0.5b"; + + // Preference 1: exact name / alias hit (V1 throws on miss, so swallow). + let model: IModel | undefined; + try { + model = await catalog.getModel(namePref); + } catch { + model = undefined; } - // Preference 2: smallest model matching the task filter. - if (model === undefined) { - const task = opts.task ?? "chat-completion"; - const matching = cachedModels.filter((m) => { - const info = m.info; - return info.task === task && info.deviceType === "CPU"; - }); - const fromCache = pickBestModel(matching); - if (fromCache !== undefined) { - model = fromCache; + // Preference 1b: catalog entries often carry a `-N` version suffix + // (e.g. `nemotron-speech-streaming-en-0.6b-generic-cpu-3`). If the exact + // hit missed, try matching by prefix before falling back to the task + // filter — caller-specified names should win over "smallest by task". + if (model === undefined && opts.namePreference !== undefined) { + const all = await catalog.getModels(); + const prefixed = all.find((m) => m.info.name.startsWith(namePref)); + if (prefixed !== undefined) { + model = prefixed; } } + // Preference 2: smallest model matching the task filter. if (model === undefined) { const task = opts.task ?? "chat-completion"; - const matching = allModels.filter((m) => { + const all = await catalog.getModels(); + const matching = all.filter((m) => { const info = m.info; return info.task === task && info.deviceType === "CPU"; }); @@ -150,7 +110,11 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`, ); } - model = pickBestModel(matching); + matching.sort( + (a, b) => + (a.info.filesizeMb ?? Number.POSITIVE_INFINITY) - (b.info.filesizeMb ?? Number.POSITIVE_INFINITY), + ); + model = matching[0]; } if (model === undefined) { manager.dispose(); From 76bbecbfc125a9d370cd45a679435a8a1f6d5a33 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Wed, 1 Jul 2026 12:33:04 -0700 Subject: [PATCH 18/20] local --- .pipelines/templates/fetch-test-data-from-blob.yml | 4 ---- sdk_v2/js/test/audio-session.test.ts | 2 +- sdk_v2/js/test/live-audio-client.test.ts | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.pipelines/templates/fetch-test-data-from-blob.yml b/.pipelines/templates/fetch-test-data-from-blob.yml index 115c6709e..95e38384f 100644 --- a/.pipelines/templates/fetch-test-data-from-blob.yml +++ b/.pipelines/templates/fetch-test-data-from-blob.yml @@ -83,10 +83,6 @@ steps: SourcePath = 'qwen3.5-0.8b' TargetAlias = 'qwen3.5-0.8b-generic-cpu-2' }, - @{ - SourcePath = 'nemotron-3.5-asr-streaming-0.6b' - TargetAlias = 'nemotron-3.5-asr-streaming-0.6b-generic-cpu-3' - }, @{ SourcePath = 'deepseek-r1-distill-qwen-14b' TargetAlias = 'deepseek-r1-distill-qwen-14b-generic-cpu-4' diff --git a/sdk_v2/js/test/audio-session.test.ts b/sdk_v2/js/test/audio-session.test.ts index 621f407d5..72b522710 100644 --- a/sdk_v2/js/test/audio-session.test.ts +++ b/sdk_v2/js/test/audio-session.test.ts @@ -394,7 +394,7 @@ describe.skipIf(!haveTestModelCache)("AudioSession (real nemotron streaming mode beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu", + namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu", task: "automatic-speech-recognition", }); }, 5 * 60_000); diff --git a/sdk_v2/js/test/live-audio-client.test.ts b/sdk_v2/js/test/live-audio-client.test.ts index 2d6925852..d3c1732c0 100644 --- a/sdk_v2/js/test/live-audio-client.test.ts +++ b/sdk_v2/js/test/live-audio-client.test.ts @@ -54,7 +54,7 @@ describe.skipIf(!haveTestModelCache)("LiveAudioTranscriptionSession (V1, real ne beforeAll(async () => { fixture = await setupRealModelManager({ - namePreference: "nemotron-3.5-asr-streaming-0.6b-generic-cpu", + namePreference: "nemotron-speech-streaming-en-0.6b-generic-cpu", task: "automatic-speech-recognition", }); if (fixture !== undefined) { From 69ad060056cfbb9780727fd1e83b8c578a1b186c Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Wed, 1 Jul 2026 12:43:43 -0700 Subject: [PATCH 19/20] correct test env var --- .pipelines/v2/templates/stages-js.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pipelines/v2/templates/stages-js.yml b/.pipelines/v2/templates/stages-js.yml index f11ed23bb..24308897f 100644 --- a/.pipelines/v2/templates/stages-js.yml +++ b/.pipelines/v2/templates/stages-js.yml @@ -203,7 +203,7 @@ stages: parameters: rid: 'win-x64' prebuildArtifactDir: '$(Pipeline.Workspace)/js-prebuild-win-x64' - testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared/Microsoft' - stage: js_test_linux_x64 displayName: 'JS SDK: Test Linux x64' @@ -227,7 +227,7 @@ stages: parameters: rid: 'linux-x64' prebuildArtifactDir: '$(Pipeline.Workspace)/js-prebuild-linux-x64' - testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared/Microsoft' - stage: js_test_osx_arm64 displayName: 'JS SDK: Test macOS ARM64' @@ -253,7 +253,7 @@ stages: parameters: rid: 'osx-arm64' prebuildArtifactDir: '$(Pipeline.Workspace)/js-prebuild-osx-arm64' - testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared' + testDataSharedDir: '$(Build.SourcesDirectory)/test-data-shared/Microsoft' # ===================================================================== # Pack stage — runs after all four builds. Pure packaging on Windows. From 5a721858065169f97e7df5927b98d0434a8fdc67 Mon Sep 17 00:00:00 2001 From: Prathik Rao Date: Wed, 1 Jul 2026 14:08:19 -0700 Subject: [PATCH 20/20] test suite --- sdk_v2/js/test/_fixtures/realModelManager.ts | 59 ++++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index 786bf427e..8a9ff919b 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -75,31 +75,58 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): }); const catalog = manager.catalog; const namePref = opts.namePreference ?? "qwen2.5-0.5b"; + const task = opts.task ?? "chat-completion"; + const all = await catalog.getModels(); + + const normalizeVersionSuffix = (value: string): string => value.replace(/-\d+$/, ""); + const nameMatchesPreference = (candidateName: string, preference: string): boolean => { + if (candidateName === preference) { + return true; + } + + const candidateBase = normalizeVersionSuffix(candidateName); + const prefBase = normalizeVersionSuffix(preference); + + // Handles versioned/unversioned pairs in either direction, e.g. + // - preference: openai-whisper-tiny-generic-cpu + // - candidate : openai-whisper-tiny-generic-cpu-4 + return candidateBase === prefBase; + }; + + const bySize = (a: IModel, b: IModel): number => + (a.info.fileSizeMb ?? Number.POSITIVE_INFINITY) - (b.info.fileSizeMb ?? Number.POSITIVE_INFINITY); + + const preferCachedThenSize = (models: IModel[]): IModel | undefined => { + const cached = models.filter((m) => m.isCached); + if (cached.length > 0) { + return cached.sort(bySize)[0]; + } + return models.sort(bySize)[0]; + }; // Preference 1: exact name / alias hit (V1 throws on miss, so swallow). let model: IModel | undefined; try { - model = await catalog.getModel(namePref); + const exact = await catalog.getModel(namePref); + if (exact.info.deviceType === "CPU") { + model = exact; + } } catch { model = undefined; } - // Preference 1b: catalog entries often carry a `-N` version suffix - // (e.g. `nemotron-speech-streaming-en-0.6b-generic-cpu-3`). If the exact - // hit missed, try matching by prefix before falling back to the task - // filter — caller-specified names should win over "smallest by task". - if (model === undefined && opts.namePreference !== undefined) { - const all = await catalog.getModels(); - const prefixed = all.find((m) => m.info.name.startsWith(namePref)); - if (prefixed !== undefined) { - model = prefixed; - } + // Preference 1b: robust preference matching with CPU + cache-first behavior. + // Handles versioned/unversioned names in either direction. + if (model === undefined) { + const preferredMatches = all.filter((m) => { + const info = m.info; + return info.deviceType === "CPU" && nameMatchesPreference(info.name, namePref); + }); + model = preferCachedThenSize(preferredMatches); } // Preference 2: smallest model matching the task filter. if (model === undefined) { - const task = opts.task ?? "chat-completion"; - const all = await catalog.getModels(); const matching = all.filter((m) => { const info = m.info; return info.task === task && info.deviceType === "CPU"; @@ -110,11 +137,7 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`, ); } - matching.sort( - (a, b) => - (a.info.filesizeMb ?? Number.POSITIVE_INFINITY) - (b.info.filesizeMb ?? Number.POSITIVE_INFINITY), - ); - model = matching[0]; + model = preferCachedThenSize(matching); } if (model === undefined) { manager.dispose();