Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions src/semantic-router/pkg/modeldownload/config_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func BuildModelSpecs(cfg *config.RouterConfig) ([]ModelSpec, error) {
paths := filterDisabledOptionalModelPaths(cfg, extractProvisioningModelPaths(cfg))
requiredFilesByModel := ExtractRequiredFilesByModel(cfg)
addEmbeddingModelRequiredFiles(cfg, requiredFilesByModel)
excludePatternsByModel := candleEmbeddingModelExcludePatterns(cfg)

// Allow empty paths for API-only configurations
if len(paths) == 0 {
Expand Down Expand Up @@ -129,10 +130,11 @@ func BuildModelSpecs(cfg *config.RouterConfig) ([]ModelSpec, error) {
}

specs = append(specs, ModelSpec{
LocalPath: path,
RepoID: repoID,
Revision: "main",
RequiredFiles: requiredFiles,
LocalPath: path,
RepoID: repoID,
Revision: "main",
RequiredFiles: requiredFiles,
ExcludePatterns: excludePatternsByModel[config.ResolveModelPath(path)],
})
}

Expand Down Expand Up @@ -204,6 +206,44 @@ func candleEmbeddingModelRequiredFiles(cfg *config.RouterConfig) map[string][]st
return required
}

// onnxWeightExcludePatterns match the ONNX inference exports published beside the
// safetensors weights in the embedding model repositories. The candle runtime never
// opens them, yet they dominate the snapshot size (about 4.3 GB of the 4.9 GB
// mmbert-embed-32k-2d-matryoshka repository), so a candle deployment skips them at
// download time. Small manifests such as onnx/model_config.json, which
// config.MmBertAvailableLayers reads, are not matched and stay in the snapshot.
var onnxWeightExcludePatterns = []string{
"*.onnx",
"*.onnx.data",
"*.onnx_data",
}

// candleEmbeddingModelExcludePatterns returns, per configured embedding model path,
// the download exclude globs for artifacts the selected embedding backend never
// loads. Only the candle backend is narrowed: OpenVINO consumes the ONNX exports
// and the remote backend provisions no local embedding models.
//
// Keys are canonical registry paths (config.ResolveModelPath), matching how the
// embedding runtime resolves the same fields before loading. Callers look the map
// up by the resolved path too, so the narrowing holds whether the configured value
// is the canonical directory or a registry alias, and whether or not the collected
// provisioning paths have already been canonicalized upstream.
func candleEmbeddingModelExcludePatterns(cfg *config.RouterConfig) map[string][]string {
excluded := make(map[string][]string)
if cfg.EmbeddingModels.EmbeddingBackend() != config.EmbeddingBackendCandle {
return excluded
}

for path := range candleEmbeddingModelRequiredFiles(cfg) {
resolved := config.ResolveModelPath(path)
if resolved == "" || !strings.HasPrefix(resolved, "models/") {
continue
}
excluded[resolved] = append([]string(nil), onnxWeightExcludePatterns...)
}
return excluded
}

// addEmbeddingModelRequiredFiles marks every configured candle embedding model as
// requiring the files its runtime hard-loads, so a partial directory (for example
// ONNX-only, or gemma without its dense-bottleneck weights) is detected as incomplete
Expand Down
142 changes: 142 additions & 0 deletions src/semantic-router/pkg/modeldownload/download_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package modeldownload

import (
"reflect"
"strings"
"testing"

"github.com/vllm-project/semantic-router/src/semantic-router/pkg/config"
)

// TestBuildModelSpecsExcludesOnnxWeightsForCandleEmbeddingModels guards the download
// scope for the default local backend: the candle runtime loads model.safetensors +
// tokenizer.json, so the multi-gigabyte ONNX exports shipped in the same repository
// must not be fetched. Every candle embedding path gets the same narrowing.
func TestBuildModelSpecsExcludesOnnxWeightsForCandleEmbeddingModels(t *testing.T) {
specs, err := BuildModelSpecs(newCandleEmbeddingConfig())
if err != nil {
t.Fatalf("BuildModelSpecs() error = %v", err)
}

for _, modelPath := range []string{
testEmbeddingModelPath,
testQwen3ModelPath,
testGemmaModelPath,
testMultiModalModelPath,
} {
spec, ok := findSpecByPath(specs, modelPath)
if !ok {
t.Fatalf("BuildModelSpecs() did not produce a spec for %q", modelPath)
}
if !reflect.DeepEqual(spec.ExcludePatterns, onnxWeightExcludePatterns) {
t.Fatalf("%s ExcludePatterns = %#v, want %#v", modelPath, spec.ExcludePatterns, onnxWeightExcludePatterns)
}
}
}

// TestBuildModelSpecsExcludesOnnxWeightsForAliasedEmbeddingModel keeps the narrowing
// attached to the model when the config names it by a registry alias. The exclude map
// is keyed and looked up by the canonical path, so it must match whether the collected
// provisioning path is the literal alias or has already been canonicalized (#2828).
func TestBuildModelSpecsExcludesOnnxWeightsForAliasedEmbeddingModel(t *testing.T) {
for _, configured := range []string{
"models/mom-embedding-ultra", // models/-prefixed alias
testEmbeddingModelPath, // canonical path
} {
t.Run(configured, func(t *testing.T) {
cfg := &config.RouterConfig{
MoMRegistry: config.ToLegacyRegistry(),
InlineModels: config.InlineModels{
EmbeddingModels: config.EmbeddingModels{MmBertModelPath: configured},
},
}

specs, err := BuildModelSpecs(cfg)
if err != nil {
t.Fatalf("BuildModelSpecs() error = %v", err)
}

found := false
for _, spec := range specs {
if config.ResolveModelPath(spec.LocalPath) != testEmbeddingModelPath {
continue
}
found = true
if !reflect.DeepEqual(spec.ExcludePatterns, onnxWeightExcludePatterns) {
t.Fatalf("%s ExcludePatterns = %#v, want %#v", spec.LocalPath, spec.ExcludePatterns, onnxWeightExcludePatterns)
}
}
if !found {
t.Fatalf("BuildModelSpecs() produced no spec resolving to %q; got %#v", testEmbeddingModelPath, specs)
}
})
}
}

// TestBuildModelSpecsKeepsFullSnapshotForOpenVINOBackend keeps ONNX deployments whole:
// the OpenVINO embedding backend consumes the ONNX exports, so it must keep receiving
// the unfiltered repository.
func TestBuildModelSpecsKeepsFullSnapshotForOpenVINOBackend(t *testing.T) {
cfg := newCandleEmbeddingConfig()
cfg.EmbeddingModels.EmbeddingConfig = config.HNSWConfig{
Backend: config.EmbeddingBackendOpenVINO,
}

specs, err := BuildModelSpecs(cfg)
if err != nil {
t.Fatalf("BuildModelSpecs() error = %v", err)
}

for _, spec := range specs {
if len(spec.ExcludePatterns) != 0 {
t.Fatalf("%s ExcludePatterns = %#v, want none for the openvino backend", spec.LocalPath, spec.ExcludePatterns)
}
}
}

// TestBuildModelSpecsLeavesNonEmbeddingModelsUnfiltered limits the blast radius to the
// embedding runtime: other locally provisioned models keep the full snapshot until their
// own runtime contract is encoded.
func TestBuildModelSpecsLeavesNonEmbeddingModelsUnfiltered(t *testing.T) {
const bertModelPath = "models/all-MiniLM-L12-v2"
cfg := newEmbeddingOnlyConfig()
cfg.MoMRegistry[bertModelPath] = "sentence-transformers/all-MiniLM-L12-v2"
cfg.BertModelPath = bertModelPath

specs, err := BuildModelSpecs(cfg)
if err != nil {
t.Fatalf("BuildModelSpecs() error = %v", err)
}

spec, ok := findSpecByPath(specs, bertModelPath)
if !ok {
t.Fatalf("BuildModelSpecs() did not produce a spec for %q; got %#v", bertModelPath, specs)
}
if len(spec.ExcludePatterns) != 0 {
t.Fatalf("%s ExcludePatterns = %#v, want none", bertModelPath, spec.ExcludePatterns)
}
}

// TestOnnxWeightExcludePatternsNeverMatchCandleRequiredFiles keeps the exclude list
// and the completeness contract aligned: a pattern that matched a hard-loaded file
// would make every download incomplete and loop forever.
func TestOnnxWeightExcludePatternsNeverMatchCandleRequiredFiles(t *testing.T) {
required := candleEmbeddingModelRequiredFiles(newCandleEmbeddingConfig())
protected := append([]string{}, DefaultRequiredFiles...)
protected = append(protected, "onnx/model_config.json")
for _, files := range required {
protected = append(protected, files...)
}

for _, pattern := range onnxWeightExcludePatterns {
suffix := strings.TrimPrefix(pattern, "*")
if suffix == pattern {
t.Fatalf("exclude pattern %q must be a suffix glob so it cannot shadow required files", pattern)
}
for _, file := range protected {
if strings.HasSuffix(file, suffix) {
t.Fatalf("exclude pattern %q matches required file %q", pattern, file)
}
}
}
}
41 changes: 30 additions & 11 deletions src/semantic-router/pkg/modeldownload/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,7 @@ func IsGatedModelError(err error, repoID string, hfToken string) bool {
func DownloadModelWithProgress(spec ModelSpec, config DownloadConfig) error {
logging.Infof("Downloading model: %s", spec.LocalPath)

// Build huggingface-cli command
args := []string{
"download",
spec.RepoID,
"--local-dir", spec.LocalPath,
}

// Add revision if specified
if spec.Revision != "" && spec.Revision != "main" {
args = append(args, "--revision", spec.Revision)
}
args := buildDownloadArgs(spec)

// Use detected CLI command, default to "hf"
cliCmd := hfCommand
Expand Down Expand Up @@ -127,6 +117,35 @@ func DownloadModelWithProgress(spec ModelSpec, config DownloadConfig) error {
return nil
}

// buildDownloadArgs assembles the huggingface-cli argument list for spec.
//
// Every exclude pattern gets its own `--exclude` flag. The typer-based `hf download`
// takes `--exclude` as a repeatable single-value option, so `--exclude a b c` keeps
// only `a` and treats `b` and `c` as extra positional filenames; the legacy
// `huggingface-cli download` accepted `nargs=*`. Repeating the flag is the form both
// CLIs parse the same way, whichever one the image ends up installing.
func buildDownloadArgs(spec ModelSpec) []string {
args := []string{
"download",
spec.RepoID,
"--local-dir", spec.LocalPath,
}

// Add revision if specified
if spec.Revision != "" && spec.Revision != "main" {
args = append(args, "--revision", spec.Revision)
}

for _, pattern := range spec.ExcludePatterns {
if pattern == "" {
continue
}
args = append(args, "--exclude", pattern)
}

return args
}

// EnsureModels ensures all required models are downloaded
func EnsureModels(specs []ModelSpec, config DownloadConfig) error {
return EnsureModelsWithProgress(specs, config, nil)
Expand Down
92 changes: 92 additions & 0 deletions src/semantic-router/pkg/modeldownload/downloader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package modeldownload

import (
"reflect"
"strings"
"testing"
)

// TestBuildDownloadArgsFetchesFullSnapshotByDefault keeps the historical contract for
// models without a narrowed download scope: repo ID plus --local-dir, nothing else.
func TestBuildDownloadArgsFetchesFullSnapshotByDefault(t *testing.T) {
spec := ModelSpec{
LocalPath: "models/category_classifier_modernbert-base_model",
RepoID: "llm-semantic-router/category_classifier_modernbert-base_model",
Revision: "main",
}

got := buildDownloadArgs(spec)
want := []string{
"download",
spec.RepoID,
"--local-dir", spec.LocalPath,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildDownloadArgs() = %#v, want %#v", got, want)
}
}

// TestBuildDownloadArgsRepeatsExcludeFlagPerPattern guards the CLI contract: the
// typer-based `hf download` takes `--exclude` as a repeatable single-value option, so
// one flag followed by several patterns silently drops all but the first and passes
// the rest as positional filenames. Each pattern must carry its own flag.
func TestBuildDownloadArgsRepeatsExcludeFlagPerPattern(t *testing.T) {
spec := ModelSpec{
LocalPath: testEmbeddingModelPath,
RepoID: testEmbeddingRepoID,
Revision: "abc123",
ExcludePatterns: []string{"*.onnx", "*.onnx.data", "*.onnx_data"},
}

got := buildDownloadArgs(spec)
want := []string{
"download",
testEmbeddingRepoID,
"--local-dir", testEmbeddingModelPath,
"--revision", "abc123",
"--exclude", "*.onnx",
"--exclude", "*.onnx.data",
"--exclude", "*.onnx_data",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildDownloadArgs() = %#v, want %#v", got, want)
}

// No two patterns may ever share one flag: with the typer CLI the second would be
// parsed as a positional filename instead of a filter.
flags := 0
for i, arg := range got {
if arg != "--exclude" {
continue
}
flags++
if i+1 >= len(got) || strings.HasPrefix(got[i+1], "-") {
t.Fatalf("buildDownloadArgs() = %#v: --exclude at %d carries no pattern", got, i)
}
}
if flags != len(spec.ExcludePatterns) {
t.Fatalf("buildDownloadArgs() = %#v: %d --exclude flags for %d patterns", got, flags, len(spec.ExcludePatterns))
}
}

// TestBuildDownloadArgsSkipsEmptyExcludePatterns keeps a stray empty entry from
// producing a bare `--exclude` that would swallow nothing or error out.
func TestBuildDownloadArgsSkipsEmptyExcludePatterns(t *testing.T) {
spec := ModelSpec{
LocalPath: testEmbeddingModelPath,
RepoID: testEmbeddingRepoID,
Revision: "main",
ExcludePatterns: []string{"", "*.onnx", ""},
}

got := buildDownloadArgs(spec)
want := []string{
"download",
testEmbeddingRepoID,
"--local-dir", testEmbeddingModelPath,
"--exclude", "*.onnx",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildDownloadArgs() = %#v, want %#v", got, want)
}
}
3 changes: 3 additions & 0 deletions src/semantic-router/pkg/modeldownload/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ type ModelSpec struct {
Revision string
// Required files to verify model completeness
RequiredFiles []string
// Glob patterns passed to `hf download --exclude` so artifacts the configured
// runtime never loads are skipped. Empty means the full snapshot is fetched.
ExcludePatterns []string
}

// DownloadConfig contains configuration for model downloading
Expand Down
Loading