From 7ed3cd71e0fd6b80956d3826837ce42653c50b20 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Thu, 25 Jun 2026 02:06:04 +0100 Subject: [PATCH 1/4] fix(embeddings): restore multi-vector textual inversion expansion transformers 5 defaults tokenizer.add_tokens() to normalized=True, so the CLIP tokenizer lowercases added embedding names. tokenizer.tokenize() then returns the lowercased surface, maybe_convert_prompt never matches mixed-case names in added_tokens_encoder, and multi-vector expansion is skipped. Every mixed-case multi-vector embedding collapsed to its first vector and looked ignored. Add embedding tokens as AddedToken(name, normalized=False) so they stay case-sensitive and tokenize() surfaces them verbatim. Valid on transformers 4.x and 5.x; convert_tokens_to_ids and encoding are unaffected. --- modules/textual_inversion.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/textual_inversion.py b/modules/textual_inversion.py index de7283810..b06000219 100644 --- a/modules/textual_inversion.py +++ b/modules/textual_inversion.py @@ -2,6 +2,7 @@ import os import time import torch import safetensors.torch +from transformers import AddedToken from modules.errorlimiter import limit_errors from modules import shared, devices, errors from modules.logger import log @@ -118,13 +119,17 @@ def deref_tokenizers(tokens, tokenizers): def insert_tokens(embeddings: list, tokenizers: list): """ Add all tokens to each tokenizer in the list, with one call to each. + normalized=False keeps tokens case-sensitive so tokenizer.tokenize surfaces them verbatim; + transformers >=5 defaults add_tokens to normalized=True, which lowercases CLIP embedding names + and breaks multi-vector expansion (maybe_convert_prompt) in prompt_parser_diffusers. """ tokens = [] for embedding in embeddings: if embedding is not None: tokens += embedding.tokens + added = [AddedToken(token, normalized=False) for token in tokens] for tokenizer in tokenizers: - tokenizer.add_tokens(tokens) + tokenizer.add_tokens(added) def insert_vectors(embedding, tokenizers, text_encoders, hiddensizes): From aeef359bd7d7d0ae149deb37305e7da8fc10635a Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Thu, 25 Jun 2026 02:06:05 +0100 Subject: [PATCH 2/4] fix(prompt): restore textual inversion on clip-skip>=2 transformers 5.6 flattened CLIPTextModel, removing the .text_model wrapper that compel_hijack and the xhinker parser dereference on the normalized clip-skip path. On SD1.5 at clip-skip >= 2 this raised AttributeError, which processing_prompt caught and silently fell back to fixed-attention encoding, dropping textual inversion and prompt weighting. Resolve the submodule via getattr(te, 'text_model', te), correct for flattened CLIPTextModel, CLIPTextModelWithProjection (still nested), and transformers < 5.6. --- modules/prompt_parser_diffusers.py | 4 +++- modules/prompt_parser_xhinker.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/prompt_parser_diffusers.py b/modules/prompt_parser_diffusers.py index 1b356d7e9..2025a4ca6 100644 --- a/modules/prompt_parser_diffusers.py +++ b/modules/prompt_parser_diffusers.py @@ -327,7 +327,9 @@ def compel_hijack(self, token_ids: torch.Tensor, attention_mask: torch.Tensor | else: hidden_state = text_encoder_output.hidden_states[-clip_skip] if normalized: - hidden_state = self.text_encoder.text_model.final_layer_norm(hidden_state) + # transformers >=5.6 flattened CLIPTextModel; CLIPTextModelWithProjection still nests it under .text_model + text_model = getattr(self.text_encoder, 'text_model', self.text_encoder) + hidden_state = text_model.final_layer_norm(hidden_state) return hidden_state diff --git a/modules/prompt_parser_xhinker.py b/modules/prompt_parser_xhinker.py index 344f0778c..1eeefe88d 100644 --- a/modules/prompt_parser_xhinker.py +++ b/modules/prompt_parser_xhinker.py @@ -216,9 +216,11 @@ def get_weighted_text_embeddings_sd15( , generator = torch.Generator(text2img_pipe.device).manual_seed(2) ).images[0] """ - original_clip_layers = pipe.text_encoder.text_model.encoder.layers + # transformers >=5.6 flattened CLIPTextModel; CLIPTextModelWithProjection still nests it under .text_model + clip_text_model = getattr(pipe.text_encoder, 'text_model', pipe.text_encoder) + original_clip_layers = clip_text_model.encoder.layers if clip_skip > 0: - pipe.text_encoder.text_model.encoder.layers = original_clip_layers[:-clip_skip] + clip_text_model.encoder.layers = original_clip_layers[:-clip_skip] eos = pipe.tokenizer.eos_token_id prompt_tokens, prompt_weights = get_prompts_tokens_with_weights( @@ -310,7 +312,7 @@ def get_weighted_text_embeddings_sd15( # recover clip layers if clip_skip > 0: - pipe.text_encoder.text_model.encoder.layers = original_clip_layers + clip_text_model.encoder.layers = original_clip_layers return prompt_embeds, neg_prompt_embeds From 3dd0cd590cd02ce0850b4ad6ce55df77622cb0d1 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Thu, 25 Jun 2026 02:06:05 +0100 Subject: [PATCH 3/4] fix(pipelines): restore clip-skip text encoding for transformers 5.6+ The vendored encode_prompt copies in the PAG, APG, ControlNet-XS and differential diffusion pipelines dereference the .text_model wrapper that transformers 5.6 removed from CLIPTextModel, so their clip-skip path crashes on SD1.5 and SDXL TE1. Apply the same getattr(te, 'text_model', te) fix as the core parser. Mirrors upstream diffusers, which still carries this deref in pipeline encode_prompt; only the single-file loader was fixed there. --- modules/apg/pipeline_stable_diffusion_apg.py | 3 ++- modules/control/units/xs_pipe.py | 3 ++- modules/pag/pipe_sd.py | 3 ++- scripts/differential_diffusion.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/apg/pipeline_stable_diffusion_apg.py b/modules/apg/pipeline_stable_diffusion_apg.py index 57aebafca..1508b3f13 100644 --- a/modules/apg/pipeline_stable_diffusion_apg.py +++ b/modules/apg/pipeline_stable_diffusion_apg.py @@ -404,7 +404,8 @@ class StableDiffusionPipelineAPG( # representations. The `last_hidden_states` that we typically use for # obtaining the final prompt representations passes through the LayerNorm # layer. - prompt_embeds = self.text_encoder.text_model.final_layer_norm(prompt_embeds) + # transformers >=5.6 flattened CLIPTextModel; CLIPTextModelWithProjection still nests it under .text_model + prompt_embeds = getattr(self.text_encoder, 'text_model', self.text_encoder).final_layer_norm(prompt_embeds) if self.text_encoder is not None: prompt_embeds_dtype = self.text_encoder.dtype diff --git a/modules/control/units/xs_pipe.py b/modules/control/units/xs_pipe.py index 282b4f996..078293831 100644 --- a/modules/control/units/xs_pipe.py +++ b/modules/control/units/xs_pipe.py @@ -1321,7 +1321,8 @@ class StableDiffusionControlNetXSPipeline( # representations. The `last_hidden_states` that we typically use for # obtaining the final prompt representations passes through the LayerNorm # layer. - prompt_embeds = self.text_encoder.text_model.final_layer_norm(prompt_embeds) + # transformers >=5.6 flattened CLIPTextModel; CLIPTextModelWithProjection still nests it under .text_model + prompt_embeds = getattr(self.text_encoder, 'text_model', self.text_encoder).final_layer_norm(prompt_embeds) if self.text_encoder is not None: prompt_embeds_dtype = self.text_encoder.dtype diff --git a/modules/pag/pipe_sd.py b/modules/pag/pipe_sd.py index 9a7af9bda..8e3f24429 100644 --- a/modules/pag/pipe_sd.py +++ b/modules/pag/pipe_sd.py @@ -619,7 +619,8 @@ class StableDiffusionPAGPipeline( # representations. The `last_hidden_states` that we typically use for # obtaining the final prompt representations passes through the LayerNorm # layer. - prompt_embeds = self.text_encoder.text_model.final_layer_norm(prompt_embeds) + # transformers >=5.6 flattened CLIPTextModel; CLIPTextModelWithProjection still nests it under .text_model + prompt_embeds = getattr(self.text_encoder, 'text_model', self.text_encoder).final_layer_norm(prompt_embeds) if self.text_encoder is not None: prompt_embeds_dtype = self.text_encoder.dtype diff --git a/scripts/differential_diffusion.py b/scripts/differential_diffusion.py index 2843bf62f..11eb177af 100644 --- a/scripts/differential_diffusion.py +++ b/scripts/differential_diffusion.py @@ -1546,7 +1546,8 @@ class StableDiffusionDiffImg2ImgPipeline(DiffusionPipeline): # representations. The `last_hidden_states` that we typically use for # obtaining the final prompt representations passes through the LayerNorm # layer. - prompt_embeds = self.text_encoder.text_model.final_layer_norm(prompt_embeds) + # transformers >=5.6 flattened CLIPTextModel; CLIPTextModelWithProjection still nests it under .text_model + prompt_embeds = getattr(self.text_encoder, 'text_model', self.text_encoder).final_layer_norm(prompt_embeds) if self.text_encoder is not None: prompt_embeds_dtype = self.text_encoder.dtype From a6fe828aa23e57bc65506c3d43fbb0adc7296612 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Thu, 25 Jun 2026 02:18:45 +0100 Subject: [PATCH 4/4] fix(api): set compatibility options without crashing clip_skip and the uni_pc_* opts live in opts.data without an OptionInfo in data_labels (compatibility_opts). Options.set() read data_labels[key].onchange unconditionally, so setting clip_skip via /sdapi/v1/options raised KeyError and returned 500; the override_settings restore path had the same unguarded data_labels[k] access for falsy-valued compat opts. Guard the onchange lookup and read the stored value via getattr(opts, k), which already falls back through data then data_labels. --- modules/options_handler.py | 3 ++- modules/processing.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/options_handler.py b/modules/options_handler.py index ffc3080ea..426349e81 100644 --- a/modules/options_handler.py +++ b/modules/options_handler.py @@ -109,7 +109,8 @@ class Options: setattr(self, key, value) except RuntimeError: return False - func = self.data_labels[key].onchange + # compatibility_opts (e.g. clip_skip) live in data without a data_labels entry + func = self.data_labels[key].onchange if key in self.data_labels else None if func is not None: try: func() diff --git a/modules/processing.py b/modules/processing.py index 8d4d6bab4..e03270794 100644 --- a/modules/processing.py +++ b/modules/processing.py @@ -153,11 +153,12 @@ def process_images(p: StableDiffusionProcessing) -> Processed | None: for k, v in p.override_settings.copy().items(): if shared.opts.data.get(k, None) is None and shared.opts.data_labels.get(k, None) is None: continue - orig = shared.opts.data.get(k, None) or shared.opts.data_labels[k].default + # getattr resolves the value via data then data_labels; compat opts (clip_skip) have no data_labels entry + orig = getattr(shared.opts, k, None) if orig == v or (type(orig) == str and os.path.splitext(orig)[0] == v): p.override_settings.pop(k, None) for k in p.override_settings.keys(): - stored_opts[k] = shared.opts.data.get(k, None) or shared.opts.data_labels[k].default + stored_opts[k] = getattr(shared.opts, k, None) results = None try: # if no checkpoint override or the override checkpoint can't be found, remove override entry and load opts checkpoint