Skip to content

Commit 21138e5

Browse files
fix support multi-subfolder downloads for Z-Image Qwen3 encoder (#8692)
* fix(model-install): support multi-subfolder downloads for Z-Image Qwen3 encoder The Z-Image Qwen3 text encoder requires both text_encoder and tokenizer subfolders from the HuggingFace repo, but the previous implementation only downloaded the text_encoder subfolder, causing model identification to fail. Changes: - Add subfolders property to HFModelSource supporting '+' separated paths - Extend filter_files() and download_urls() to handle multiple subfolders - Update _multifile_download() to preserve subfolder structure - Make Qwen3Encoder probe check both nested and direct config.json paths - Update Qwen3EncoderLoader to handle both directory structures - Change starter model source to text_encoder+tokenizer * ruff format * fix schema description * fix schema description --------- Co-authored-by: Lincoln Stein <[email protected]>
1 parent 39114b0 commit 21138e5

File tree

8 files changed

+154
-37
lines changed

8 files changed

+154
-37
lines changed

invokeai/app/services/model_install/model_install_common.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ def __str__(self) -> str:
8585

8686
class HFModelSource(StringLikeSource):
8787
"""
88-
A HuggingFace repo_id with optional variant, sub-folder and access token.
88+
A HuggingFace repo_id with optional variant, sub-folder(s) and access token.
8989
Note that the variant option, if not provided to the constructor, will default to fp16, which is
9090
what people (almost) always want.
91+
92+
The subfolder can be a single path or multiple paths joined by '+' (e.g., "text_encoder+tokenizer").
93+
When multiple subfolders are specified, all of them will be downloaded and combined into the model directory.
9194
"""
9295

9396
repo_id: str
@@ -103,6 +106,16 @@ def proper_repo_id(cls, v: str) -> str: # noqa D102
103106
raise ValueError(f"{v}: invalid repo_id format")
104107
return v
105108

109+
@property
110+
def subfolders(self) -> list[Path]:
111+
"""Return list of subfolders (supports '+' separated multiple subfolders)."""
112+
if self.subfolder is None:
113+
return []
114+
subfolder_str = self.subfolder.as_posix()
115+
if "+" in subfolder_str:
116+
return [Path(s.strip()) for s in subfolder_str.split("+")]
117+
return [self.subfolder]
118+
106119
def __str__(self) -> str:
107120
"""Return string version of repoid when string rep needed."""
108121
base: str = self.repo_id

invokeai/app/services/model_install/model_install_default.py

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,15 @@ def download_and_cache_model(
417417
model_path.mkdir(parents=True, exist_ok=True)
418418
model_source = self._guess_source(str(source))
419419
remote_files, _ = self._remote_files_from_source(model_source)
420+
# Handle multiple subfolders for HFModelSource
421+
subfolders = model_source.subfolders if isinstance(model_source, HFModelSource) else []
420422
job = self._multifile_download(
421423
dest=model_path,
422424
remote_files=remote_files,
423-
subfolder=model_source.subfolder if isinstance(model_source, HFModelSource) else None,
425+
subfolder=model_source.subfolder
426+
if isinstance(model_source, HFModelSource) and len(subfolders) <= 1
427+
else None,
428+
subfolders=subfolders if len(subfolders) > 1 else None,
424429
)
425430
files_string = "file" if len(remote_files) == 1 else "files"
426431
self._logger.info(f"Queuing model download: {source} ({len(remote_files)} {files_string})")
@@ -438,10 +443,13 @@ def _remote_files_from_source(
438443
if isinstance(source, HFModelSource):
439444
metadata = HuggingFaceMetadataFetch(self._session).from_id(source.repo_id, source.variant)
440445
assert isinstance(metadata, ModelMetadataWithFiles)
446+
# Use subfolders property which handles '+' separated multiple subfolders
447+
subfolders = source.subfolders
441448
return (
442449
metadata.download_urls(
443450
variant=source.variant or self._guess_variant(),
444-
subfolder=source.subfolder,
451+
subfolder=source.subfolder if len(subfolders) <= 1 else None,
452+
subfolders=subfolders if len(subfolders) > 1 else None,
445453
session=self._session,
446454
),
447455
metadata,
@@ -741,10 +749,13 @@ def _import_remote_model(
741749
install_job._install_tmpdir = destdir
742750
install_job.total_bytes = sum((x.size or 0) for x in remote_files)
743751

752+
# Handle multiple subfolders for HFModelSource
753+
subfolders = source.subfolders if isinstance(source, HFModelSource) else []
744754
multifile_job = self._multifile_download(
745755
remote_files=remote_files,
746756
dest=destdir,
747-
subfolder=source.subfolder if isinstance(source, HFModelSource) else None,
757+
subfolder=source.subfolder if isinstance(source, HFModelSource) and len(subfolders) <= 1 else None,
758+
subfolders=subfolders if len(subfolders) > 1 else None,
748759
access_token=source.access_token,
749760
submit_job=False, # Important! Don't submit the job until we have set our _download_cache dict
750761
)
@@ -771,31 +782,69 @@ def _multifile_download(
771782
remote_files: List[RemoteModelFile],
772783
dest: Path,
773784
subfolder: Optional[Path] = None,
785+
subfolders: Optional[List[Path]] = None,
774786
access_token: Optional[str] = None,
775787
submit_job: bool = True,
776788
) -> MultiFileDownloadJob:
777789
# HuggingFace repo subfolders are a little tricky. If the name of the model is "sdxl-turbo", and
778790
# we are installing the "vae" subfolder, we do not want to create an additional folder level, such
779791
# as "sdxl-turbo/vae", nor do we want to put the contents of the vae folder directly into "sdxl-turbo".
780792
# So what we do is to synthesize a folder named "sdxl-turbo_vae" here.
781-
if subfolder:
793+
#
794+
# For multiple subfolders (e.g., text_encoder+tokenizer), we create a combined folder name
795+
# (e.g., sdxl-turbo_text_encoder_tokenizer) and keep each subfolder's contents in its own
796+
# subdirectory within the model folder.
797+
798+
if subfolders and len(subfolders) > 1:
799+
# Multiple subfolders: create combined name and keep subfolder structure
800+
top = Path(remote_files[0].path.parts[0]) # e.g. "Z-Image-Turbo/"
801+
subfolder_names = [sf.name.replace("/", "_").replace("\\", "_") for sf in subfolders]
802+
combined_name = "_".join(subfolder_names)
803+
path_to_add = Path(f"{top}_{combined_name}")
804+
805+
parts: List[RemoteModelFile] = []
806+
for model_file in remote_files:
807+
assert model_file.size is not None
808+
# Determine which subfolder this file belongs to
809+
file_path = model_file.path
810+
new_path: Optional[Path] = None
811+
for sf in subfolders:
812+
try:
813+
# Try to get relative path from this subfolder
814+
relative = file_path.relative_to(top / sf)
815+
# Keep the subfolder name as a subdirectory
816+
new_path = path_to_add / sf.name / relative
817+
break
818+
except ValueError:
819+
continue
820+
821+
if new_path is None:
822+
# File doesn't match any subfolder, keep original path structure
823+
new_path = path_to_add / file_path.relative_to(top)
824+
825+
parts.append(RemoteModelFile(url=model_file.url, path=new_path))
826+
elif subfolder:
827+
# Single subfolder: flatten into renamed folder
782828
top = Path(remote_files[0].path.parts[0]) # e.g. "sdxl-turbo/"
783829
path_to_remove = top / subfolder # sdxl-turbo/vae/
784830
subfolder_rename = subfolder.name.replace("/", "_").replace("\\", "_")
785831
path_to_add = Path(f"{top}_{subfolder_rename}")
786-
else:
787-
path_to_remove = Path(".")
788-
path_to_add = Path(".")
789-
790-
parts: List[RemoteModelFile] = []
791-
for model_file in remote_files:
792-
assert model_file.size is not None
793-
parts.append(
794-
RemoteModelFile(
795-
url=model_file.url, # if a subfolder, then sdxl-turbo_vae/config.json
796-
path=path_to_add / model_file.path.relative_to(path_to_remove),
832+
833+
parts = []
834+
for model_file in remote_files:
835+
assert model_file.size is not None
836+
parts.append(
837+
RemoteModelFile(
838+
url=model_file.url,
839+
path=path_to_add / model_file.path.relative_to(path_to_remove),
840+
)
797841
)
798-
)
842+
else:
843+
# No subfolder specified - pass through unchanged
844+
parts = []
845+
for model_file in remote_files:
846+
assert model_file.size is not None
847+
parts.append(RemoteModelFile(url=model_file.url, path=model_file.path))
799848

800849
return self._download_queue.multifile_download(
801850
parts=parts,

invokeai/backend/model_manager/configs/qwen3_encoder.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,23 @@ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -
9494

9595
raise_for_override_fields(cls, override_fields)
9696

97-
# Check for text_encoder config
98-
expected_config_path = mod.path / "text_encoder" / "config.json"
97+
# Check for text_encoder config - support both:
98+
# 1. Full model structure: model_root/text_encoder/config.json
99+
# 2. Standalone text_encoder download: model_root/config.json (when text_encoder subfolder is downloaded separately)
100+
config_path_nested = mod.path / "text_encoder" / "config.json"
101+
config_path_direct = mod.path / "config.json"
102+
103+
if config_path_nested.exists():
104+
expected_config_path = config_path_nested
105+
elif config_path_direct.exists():
106+
expected_config_path = config_path_direct
107+
else:
108+
from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
109+
110+
raise NotAMatchError(
111+
f"unable to load config file(s): {{PosixPath('{config_path_nested}'): 'file does not exist'}}"
112+
)
113+
99114
# Qwen3 uses Qwen2VLForConditionalGeneration or similar
100115
raise_for_class_name(
101116
expected_config_path,

invokeai/backend/model_manager/load/model_loaders/z_image.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,30 @@ def _load_model(
367367
if not isinstance(config, Qwen3Encoder_Qwen3Encoder_Config):
368368
raise ValueError("Only Qwen3Encoder_Qwen3Encoder_Config models are supported here.")
369369

370+
model_path = Path(config.path)
371+
372+
# Support both structures:
373+
# 1. Full model: model_root/text_encoder/ and model_root/tokenizer/
374+
# 2. Standalone download: model_root/ contains text_encoder files directly
375+
text_encoder_path = model_path / "text_encoder"
376+
tokenizer_path = model_path / "tokenizer"
377+
378+
# Check if this is a standalone text_encoder download (no nested text_encoder folder)
379+
is_standalone = not text_encoder_path.exists() and (model_path / "config.json").exists()
380+
381+
if is_standalone:
382+
text_encoder_path = model_path
383+
tokenizer_path = model_path # Tokenizer files should also be in root
384+
370385
match submodel_type:
371386
case SubModelType.Tokenizer:
372-
return AutoTokenizer.from_pretrained(Path(config.path) / "tokenizer")
387+
return AutoTokenizer.from_pretrained(tokenizer_path)
373388
case SubModelType.TextEncoder:
374389
# Determine safe dtype based on target device capabilities
375390
target_device = TorchDevice.choose_torch_device()
376391
model_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
377392
return Qwen3ForCausalLM.from_pretrained(
378-
Path(config.path) / "text_encoder",
393+
text_encoder_path,
379394
torch_dtype=model_dtype,
380395
low_cpu_mem_usage=True,
381396
)

invokeai/backend/model_manager/metadata/metadata_base.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,15 @@ def download_urls(
9595
self,
9696
variant: Optional[ModelRepoVariant] = None,
9797
subfolder: Optional[Path] = None,
98+
subfolders: Optional[List[Path]] = None,
9899
session: Optional[Session] = None,
99100
) -> List[RemoteModelFile]:
100101
"""
101-
Return list of downloadable files, filtering by variant and subfolder, if any.
102+
Return list of downloadable files, filtering by variant and subfolder(s), if any.
102103
103104
:param variant: Return model files needed to reconstruct the indicated variant
104-
:param subfolder: Return model files from the designated subfolder only
105+
:param subfolder: Return model files from the designated subfolder only (deprecated, use subfolders)
106+
:param subfolders: Return model files from the designated subfolders
105107
:param session: A request.Session object used for internet-free testing
106108
107109
Note that there is special variant-filtering behavior here:
@@ -111,10 +113,15 @@ def download_urls(
111113
session = session or Session()
112114
configure_http_backend(backend_factory=lambda: session) # used in testing
113115

114-
paths = filter_files([x.path for x in self.files], variant, subfolder) # all files in the model
115-
prefix = f"{subfolder}/" if subfolder else ""
116+
paths = filter_files([x.path for x in self.files], variant, subfolder, subfolders) # all files in the model
117+
118+
# Determine prefix for model_index.json check - only applies for single subfolder
119+
prefix = ""
120+
if subfolder and not subfolders:
121+
prefix = f"{subfolder}/"
122+
116123
# the next step reads model_index.json to determine which subdirectories belong
117-
# to the model
124+
# to the model (only for single subfolder case)
118125
if Path(f"{prefix}model_index.json") in paths:
119126
url = hf_hub_url(self.id, filename="model_index.json", subfolder=str(subfolder) if subfolder else None)
120127
resp = session.get(url)

invokeai/backend/model_manager/starter_models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,8 +694,8 @@ class StarterModelBundle(BaseModel):
694694
z_image_qwen3_encoder = StarterModel(
695695
name="Z-Image Qwen3 Text Encoder",
696696
base=BaseModelType.Any,
697-
source="Tongyi-MAI/Z-Image-Turbo::text_encoder",
698-
description="Qwen3 4B text encoder for Z-Image (full precision). ~8GB",
697+
source="Tongyi-MAI/Z-Image-Turbo::text_encoder+tokenizer",
698+
description="Qwen3 4B text encoder with tokenizer for Z-Image (full precision). ~8GB",
699699
type=ModelType.Qwen3Encoder,
700700
)
701701

invokeai/backend/model_manager/util/select_hf_files.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,39 @@ def filter_files(
2424
files: List[Path],
2525
variant: Optional[ModelRepoVariant] = None,
2626
subfolder: Optional[Path] = None,
27+
subfolders: Optional[List[Path]] = None,
2728
) -> List[Path]:
2829
"""
2930
Take a list of files in a HuggingFace repo root and return paths to files needed to load the model.
3031
3132
:param files: List of files relative to the repo root.
32-
:param subfolder: Filter by the indicated subfolder.
33+
:param subfolder: Filter by the indicated subfolder (deprecated, use subfolders instead).
34+
:param subfolders: Filter by multiple subfolders. Files from any of these subfolders will be included.
3335
:param variant: Filter by files belonging to a particular variant, such as fp16.
3436
3537
The file list can be obtained from the `files` field of HuggingFaceMetadata,
3638
as defined in `invokeai.backend.model_manager.metadata.metadata_base`.
3739
"""
3840
variant = variant or ModelRepoVariant.Default
3941
paths: List[Path] = []
40-
root = files[0].parts[0]
42+
43+
if not files:
44+
return []
45+
46+
root = files[0].parts[0] if files[0].parts else Path(".")
47+
48+
# Build list of subfolders to filter by
49+
filter_subfolders: List[Path] = []
50+
if subfolders:
51+
filter_subfolders = subfolders
52+
elif subfolder:
53+
filter_subfolders = [subfolder]
4154

4255
# if the subfolder is a single file, then bypass the selection and just return it
43-
if subfolder and subfolder.suffix in [".safetensors", ".bin", ".onnx", ".xml", ".pth", ".pt", ".ckpt", ".msgpack"]:
44-
return [root / subfolder]
56+
if len(filter_subfolders) == 1:
57+
sf = filter_subfolders[0]
58+
if sf.suffix in [".safetensors", ".bin", ".onnx", ".xml", ".pth", ".pt", ".ckpt", ".msgpack"]:
59+
return [root / sf]
4560

4661
# Start by filtering on model file extensions, discarding images, docs, etc
4762
for file in files:
@@ -66,10 +81,10 @@ def filter_files(
6681
elif re.search(r"model.*\.(safetensors|bin|onnx|xml|pth|pt|ckpt|msgpack)$", file.name):
6782
paths.append(file)
6883

69-
# limit search to subfolder if requested
70-
if subfolder:
71-
subfolder = root / subfolder
72-
paths = [x for x in paths if Path(subfolder) in x.parents]
84+
# limit search to subfolder(s) if requested
85+
if filter_subfolders:
86+
absolute_subfolders = [root / sf for sf in filter_subfolders]
87+
paths = [x for x in paths if any(Path(sf) in x.parents for sf in absolute_subfolders)]
7388

7489
# _filter_by_variant uniquifies the paths and returns a set
7590
return sorted(_filter_by_variant(paths, variant))

invokeai/frontend/web/src/services/api/schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9557,9 +9557,12 @@ export type components = {
95579557
};
95589558
/**
95599559
* HFModelSource
9560-
* @description A HuggingFace repo_id with optional variant, sub-folder and access token.
9560+
* @description A HuggingFace repo_id with optional variant, sub-folder(s) and access token.
95619561
* Note that the variant option, if not provided to the constructor, will default to fp16, which is
95629562
* what people (almost) always want.
9563+
*
9564+
* The subfolder can be a single path or multiple paths joined by '+' (e.g., "text_encoder+tokenizer").
9565+
* When multiple subfolders are specified, all of them will be downloaded and combined into the model directory.
95639566
*/
95649567
HFModelSource: {
95659568
/** Repo Id */

0 commit comments

Comments
 (0)