Skip to content
Merged
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
9 changes: 5 additions & 4 deletions invokeai/app/api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
def configure_gzip(app: FastAPI, compresslevel: int) -> None:
"""Install response compression, unless it is turned off.

Compression runs on the event loop, so its cost is not paid by the requesting client alone —
it stalls every other request and every socket.io event for its duration. That makes the
level a real trade-off rather than a free win.
Body chunks under 128 KiB are compressed on the event loop (Starlette moves larger ones to a
worker thread), so the cost is not paid by the requesting client alone — it stalls every other
request and every socket.io event for its duration, and offloaded chunks still compete for CPU.
That makes the level a real trade-off rather than a free win.

Measured on the flat name list of a 200k-image library (8.48 MB of JSON): level 1 takes
16.4ms and returns 6.1% of the input, level 9 takes 90.2ms and returns 5.7%. Level 9 costs
5.5x the event-loop time for 0.4 percentage points of bandwidth, which is a poor deal for a
5.5x the compression time for 0.4 percentage points of bandwidth, which is a poor deal for a
locally-served app. The default stays at 9 so behavior is unchanged for existing installs;
users who feel the stall on a large library can lower it.

Expand Down
35 changes: 9 additions & 26 deletions invokeai/app/services/auth/token_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime, timedelta, timezone
from typing import cast

from jose import JWTError, jwt
import jwt
from pydantic import BaseModel

ALGORITHM = "HS256"
Expand Down Expand Up @@ -83,29 +83,11 @@ def verify_token(token: str) -> TokenData | None:
TokenData if valid, None if invalid or expired
"""
try:
# python-jose 3.5.0 has a bug where exp verification doesn't work properly
# We need to manually check expiration, but MUST verify signature first
# to prevent accepting tokens with valid payloads but invalid signatures

# First, verify the signature - this will raise JWTError if signature is invalid
# Note: python-jose won't reject expired tokens here due to the bug
payload = jwt.decode(
token,
get_jwt_secret(),
algorithms=[ALGORITHM],
)

# Now manually check expiration (because python-jose 3.5.0 doesn't do this properly)
if "exp" in payload:
exp_timestamp = payload["exp"]
current_timestamp = datetime.now(timezone.utc).timestamp()
if current_timestamp >= exp_timestamp:
# Token is expired
return None

# PyJWT verifies the signature before the claims, and rejects an expired `exp`.
payload = jwt.decode(token, get_jwt_secret(), algorithms=[ALGORITHM])
return TokenData(**payload)
except JWTError:
# Token is invalid (bad signature, malformed, etc.)
except jwt.PyJWTError:
# Token is invalid (bad signature, expired, malformed, etc.)
return None
except Exception:
# Catch any other exceptions (e.g., Pydantic validation errors)
Expand All @@ -122,11 +104,12 @@ def get_token_remaining_seconds(token: str) -> int | None:
if verify_token(token) is None:
return None
try:
claims = jwt.get_unverified_claims(token)
except JWTError:
claims = jwt.decode(token, options={"verify_signature": False})
except jwt.PyJWTError:
return None
exp = claims.get("exp")
if exp is None:
return int(timedelta(hours=DEFAULT_EXPIRATION_HOURS).total_seconds())
remaining = int(cast(float, exp) - datetime.now(timezone.utc).timestamp())
# PyJWT accepted the claim only if `int(exp)` succeeds, which also admits a numeric string.
remaining = int(float(exp) - datetime.now(timezone.utc).timestamp())
return remaining if remaining > 0 else None
81 changes: 0 additions & 81 deletions invokeai/backend/minimax_h3/qwen3vl_vision_device_patch.py

This file was deleted.

18 changes: 10 additions & 8 deletions invokeai/backend/model_manager/load/model_loaders/flux.py
Original file line number Diff line number Diff line change
Expand Up @@ -2278,20 +2278,22 @@ def _load_sdnq_clip(self, clip_path: Path) -> AnyModel:
with accelerate.init_empty_weights():
model = CLIPTextModel(model_config)

# transformers >=5.6 flattened CLIPTextModel, dropping the `text_model.` prefix the checkpoint carries.
# (`from_pretrained` strips it with a conversion rule; `load_state_dict` does not.)
sd = {k.removeprefix("text_model."): v for k, v in sd.items()}

# position_ids is a non-persistent buffer that may be absent from the checkpoint.
missing, unexpected = model.load_state_dict(sd, strict=False, assign=True)
raise_on_incomplete_sdnq_load(
"SDNQ CLIP text encoder", missing, unexpected, allowed_missing={"text_model.embeddings.position_ids"}
"SDNQ CLIP text encoder", missing, unexpected, allowed_missing={"embeddings.position_ids"}
)

# Dequantize embedding layer
if hasattr(model, "text_model") and hasattr(model.text_model, "embeddings"):
embed_weight = model.text_model.embeddings.token_embedding.weight
if isinstance(embed_weight, SDNQTensor):
dequantized = embed_weight.get_dequantized_tensor()
model.text_model.embeddings.token_embedding.weight = torch.nn.Parameter(
dequantized, requires_grad=False
)
embed_weight = model.embeddings.token_embedding.weight
if isinstance(embed_weight, SDNQTensor):
model.embeddings.token_embedding.weight = torch.nn.Parameter(
embed_weight.get_dequantized_tensor(), requires_grad=False
)

return model

Expand Down
50 changes: 2 additions & 48 deletions invokeai/backend/model_manager/load/model_loaders/ltx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ class here. A folder that also holds a transformer file serves that too.
"""

import json
import logging
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from collections.abc import Callable
from pathlib import Path
from typing import Any, Optional

Expand Down Expand Up @@ -85,48 +82,6 @@ class here. A folder that also holds a transformer file serves that too.
)
_COMPONENT_SUPPORTED_NOTE = "LTX-2 component files (VAEs, vocoder, connectors, upsamplers) must be unquantized."

# ``AutoTokenizer`` sniffs the folder's ``config.json`` before it reads ``tokenizer_config.json``.
# The release declares ``gemma4_unified_text``, which this transformers version does not register,
# so ``AutoConfig`` raises and the fallback builds the base ``PreTrainedConfig``, whose
# ``model_type`` is "" -- reported at warning level as "You are using a model of type
# `gemma4_unified_text` to instantiate a model of type ``". That sniffed config is then discarded:
# the tokenizer class comes from ``tokenizer_config.json``, which names ``GemmaTokenizer``.
#
# The marker names the released type because the same line reports every unregistered one, with the
# declared type interpolated: the probe in ``configs/gemma4_encoder.py`` also matches a folder on
# ``architectures`` alone, so a repack declaring something else must keep the one message that names
# what it declares. The filter sits on the emitting logger so it runs in ``Logger.handle``, before
# the record reaches transformers' own handler (transformers attaches one to its root logger and,
# outside CI, does not propagate to InvokeAI's), and is scoped to the loading thread because
# verbosity is process-global and loads run alongside generation and the image index worker.
_CONFIG_LOGGER_NAME = "transformers.configuration_utils"
_RELEASE_MODEL_TYPE = "gemma4_unified_text"
_UNREGISTERED_MODEL_TYPE_MARKER = f"model of type `{_RELEASE_MODEL_TYPE}` to instantiate"

_tokenizer_load = threading.local()


class _UnregisteredModelTypeFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if not getattr(_tokenizer_load, "active", False):
return True
return _UNREGISTERED_MODEL_TYPE_MARKER not in record.getMessage()


logging.getLogger(_CONFIG_LOGGER_NAME).addFilter(_UnregisteredModelTypeFilter())


@contextmanager
def _quiet_unregistered_model_type() -> Iterator[None]:
"""Drop the config-sniff mismatch warning for a tokenizer load made on this thread."""
previously_active = getattr(_tokenizer_load, "active", False)
_tokenizer_load.active = True
try:
yield
finally:
_tokenizer_load.active = previously_active


_ROLE_LABELS = {
layout.ROLE_VIDEO_VAE: "video VAE",
layout.ROLE_AUDIO_VAE: "audio VAE",
Expand Down Expand Up @@ -518,8 +473,7 @@ def _load_model(
# ("inconsistent" becomes "in" + "consistent"), which would mis-tokenize every prompt.
# Passing it explicitly keeps the released pre-tokenizer -- what the reference
# pipelines encode with -- and states the decision instead of repeating the advice.
with _quiet_unregistered_model_type():
return AutoTokenizer.from_pretrained(root, local_files_only=True, fix_mistral_regex=False)
return AutoTokenizer.from_pretrained(root, local_files_only=True, fix_mistral_regex=False)
case SubModelType.TextEncoder:
return self._load_text_encoder(root / config.weight_file, root / "config.json")
case _:
Expand Down
15 changes: 0 additions & 15 deletions invokeai/backend/model_manager/load/model_loaders/minimax_h3.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,6 @@ def _load_model(

from transformers import AutoConfig, Qwen3VLForConditionalGeneration

from invokeai.backend.minimax_h3.qwen3vl_vision_device_patch import (
apply_qwen3vl_vision_pos_embed_device_patch,
)

# Keep the vision tower's pos-embed interpolation on the compute device when the
# partial loader leaves pos_embed.weight on the CPU; see the patch module.
apply_qwen3vl_vision_pos_embed_device_patch()

te_config = normalize_qwen3vl_rope_config(
AutoConfig.from_pretrained(submodel_path, local_files_only=True)
)
Expand Down Expand Up @@ -354,9 +346,6 @@ def _load_text_encoder_from_singlefile(self, config: AnyModelConfig) -> AnyModel
from safetensors.torch import load_file
from transformers import Qwen3VLConfig, Qwen3VLForConditionalGeneration

from invokeai.backend.minimax_h3.qwen3vl_vision_device_patch import (
apply_qwen3vl_vision_pos_embed_device_patch,
)
from invokeai.backend.minimax_h3.text_conditioning import MINIMAX_H3_TEXT_ENCODER_LAYER
from invokeai.backend.model_manager.load.model_loaders.minimax_h3_state_dict_utils import (
convert_minimax_h3_text_encoder_checkpoint,
Expand Down Expand Up @@ -395,10 +384,6 @@ def _load_text_encoder_from_singlefile(self, config: AnyModelConfig) -> AnyModel
config_dict["tie_word_embeddings"] = True
te_config = normalize_qwen3vl_rope_config(Qwen3VLConfig.from_dict(config_dict))

# Keep the vision tower's pos-embed interpolation on the compute device when the partial
# loader leaves pos_embed.weight on the CPU; see the patch module.
apply_qwen3vl_vision_pos_embed_device_patch()

with accelerate.init_empty_weights():
model = Qwen3VLForConditionalGeneration._from_config(te_config)

Expand Down
14 changes: 5 additions & 9 deletions invokeai/backend/model_manager/load/model_loaders/qwen_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,17 @@ def _remap_qwen_vl_checkpoint_keys(sd: dict) -> dict:
`Qwen2_5_VLForConditionalGeneration.from_pretrained` would, since
`load_state_dict` does not.

transformers ≤4.x exposed this as `_checkpoint_conversion_mapping`, but 5.x
dropped it (returns `{}`), so we fall back to the legacy mapping ourselves. The
negative lookahead keeps already-converted keys untouched, so the remap is safe
(and idempotent) for both legacy and new-layout single-file checkpoints.
transformers ≤4.x exposed this mapping as `_checkpoint_conversion_mapping`; 5.x
emptied and then removed it, so we carry it ourselves. The negative lookahead keeps
already-converted keys untouched, so the remap is safe (and idempotent) for both
legacy and new-layout single-file checkpoints.
"""
import re

from transformers import Qwen2_5_VLForConditionalGeneration

key_mapping = Qwen2_5_VLForConditionalGeneration._checkpoint_conversion_mapping or {
key_mapping = {
r"^visual": "model.visual",
r"^model(?!\.(language_model|visual))": "model.language_model",
}
if not key_mapping:
return sd

remapped_sd: dict = {}
for old_key, tensor in sd.items():
Expand Down
6 changes: 4 additions & 2 deletions invokeai/backend/model_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,18 @@ def apply_clip_skip(
text_encoder: Union[CLIPTextModel, CLIPTextModelWithProjection],
clip_skip: int,
) -> Generator[None, Any, Any]:
# transformers >=5.6 flattened CLIPTextModel (no `text_model` wrapper); CLIPTextModelWithProjection still wraps.
layers = getattr(text_encoder, "text_model", text_encoder).encoder.layers
skipped_layers = []
try:
for _i in range(clip_skip):
skipped_layers.append(text_encoder.text_model.encoder.layers.pop(-1))
skipped_layers.append(layers.pop(-1))

yield

finally:
while len(skipped_layers) > 0:
text_encoder.text_model.encoder.layers.append(skipped_layers.pop())
layers.append(skipped_layers.pop())

@classmethod
@contextmanager
Expand Down
6 changes: 6 additions & 0 deletions invokeai/backend/patches/layer_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Dict, Iterable, Optional, Tuple

import torch
from transformers import CLIPTextModel

from invokeai.backend.model_manager.load.model_cache.model_cache import MODEL_LOAD_LOCK
from invokeai.backend.patches.layers.base_layer_patch import BaseLayerPatch
Expand Down Expand Up @@ -390,6 +391,11 @@ def _get_submodule(
Returns:
tuple[str, torch.nn.Module]: A tuple containing the module key and the submodule.
"""
# LoRA formats address CLIP text encoder 1 under `text_model`, a wrapper transformers >=5.6 removed from
# CLIPTextModel (CLIPTextModelWithProjection keeps it), so drop the prefix for that class.
if isinstance(model, CLIPTextModel):
layer_key = layer_key.removeprefix("text_model_" if layer_key_is_flattened else "text_model.")

if not layer_key_is_flattened:
return layer_key, model.get_submodule(layer_key)

Expand Down
Loading
Loading