Skip to content
Open
6 changes: 5 additions & 1 deletion invokeai/backend/model_manager/configs/mistral_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,14 @@ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -
return cls(variant=variant, **override_fields)


# Comfy-Org ``*_fp4_mixed`` files probe as checkpoints here, but the loader cannot dequantize them:
# it scales the packed uint8 instead of unpacking the two nibbles a byte holds, which is where the
# shape mismatch in issue #9565 comes from. Rejecting them at probe is a follow-up. Kept out of the
# class docstring below because pydantic publishes that as the schema description.
class MistralEncoder_Checkpoint_Config(Checkpoint_Config_Base, Config_Base):
"""Configuration for a single-file Mistral text encoder (safetensors).

Accepts both 30-layer cow (Comfy-Org bf16/fp8/fp4) and 40-layer Mistral Small 3
Accepts both 30-layer cow (Comfy-Org bf16/fp8) and 40-layer Mistral Small 3
(BFL canonical / upstream Mistral 3.x single-files). The loader uses the
detected variant to decide whether to keep or strip the final RMSNorm.
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
FLUX.2 [dev] uses BFL's 30-layer "cow-mistral3-small" distillation as its sole
text encoder. The diffusers release wraps it in the multimodal
``Mistral3ForConditionalGeneration``; standalone single-file safetensors
(Comfy-Org bf16/fp8/fp4) and GGUF redistributions (gguf-org cow variants) ship
(Comfy-Org bf16/fp8) and GGUF redistributions (gguf-org cow variants) ship
only the text tower, which we load as an encoder-only ``MistralModel``.

Comfy-Org's ``*_fp4_mixed`` files are deliberately not listed: FP4 packs two
values per byte, and the dequantization below multiplies the packed uint8 by the
scale instead of unpacking the nibbles, so it produces the wrong tensor (and a
shape mismatch at load - issue #9565). Rejecting them outright is a follow-up.

Both single-file packagings embed the canonical Tekken tokenizer as a U8 tensor
named ``tekken_model`` (~19 MB). When ``mistral_common`` is installed we use
that embedded tokenizer directly; otherwise we fall back to fetching the
Expand Down Expand Up @@ -340,18 +345,24 @@ def _warn_if_40_layer_mistral(variant: MistralVariantType, logger: Any) -> None:
"If this is NOT BFL's canonical FLUX.2-dev/text_encoder, expect degraded "
"prompt adherence — upstream Mistral 3.1 / 3.2 weights (GGUFs from "
"unsloth, gguf-org, etc.) are not what FLUX.2's joint attention was "
"trained against. Recommended encoders: Comfy-Org bf16/fp8/fp4 or "
"trained against. Recommended encoders: Comfy-Org bf16/fp8 or "
"gguf-org cow-mistral3-small quants (all 30-layer cow distillation)."
)


def _drop_quantization_metadata(sd: dict[str, Any], logger, target_dtype: torch.dtype | None = None) -> dict[str, Any]:
"""Dequantize Comfy-Org-style FP8/FP4 weights and drop their metadata keys.
"""Dequantize Comfy-Org-style FP8 weights and drop their metadata keys.

Comfy-Org's Mistral FLUX.2 redistributions store quantized weights alongside
``*.weight_scale`` (and occasionally ``*.input_scale``) tensors. We apply the
scale in-place and remove the metadata so transformers can load the result.

FP8 only. This multiplies the stored weight by its scale, which is correct for
one value per byte and wrong for FP4, where a byte holds two packed nibbles
that have to be unpacked first - the source of the shape mismatch in #9565.
``*_fp4_mixed`` files are not supported; the starter entry for one was removed
rather than left pointing at a 12 GB download that cannot load.

Dequantization runs in fp32 for numerical accuracy, but each result is cast
back down to ``target_dtype`` immediately (when provided) so the transient peak
is a single fp32 weight at a time rather than the whole dict held at fp32. For a
Expand Down
9 changes: 0 additions & 9 deletions invokeai/backend/model_manager/starter_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,14 +1221,6 @@ class StarterModelBundle(BaseModel):
type=ModelType.MistralEncoder,
)

flux2_dev_comfy_mistral_fp4 = StarterModel(
name="FLUX.2 [dev] Mistral Encoder (Comfy FP4 mixed)",
base=BaseModelType.Any,
source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp4_mixed.safetensors",
description="Comfy-Org FP4-mixed of BFL's 30-layer cow-mistral3-small. Smallest safetensors variant; embeds Tekken tokenizer. ~12.3GB",
type=ModelType.MistralEncoder,
)

# gguf-org cow GGUF variants (30-layer cow, llama.cpp packaging, also embed Tekken).
# Lower memory footprint than the Comfy safetensors but slightly lower fidelity.
flux2_dev_cow_mistral_q4 = StarterModel(
Expand Down Expand Up @@ -2375,7 +2367,6 @@ def _gemini_3_resolution_presets(
flux2_klein_qwen3_4b_encoder,
flux2_klein_qwen3_8b_encoder,
flux2_dev_comfy_mistral_bf16,
flux2_dev_comfy_mistral_fp4,
flux2_dev_comfy_mistral_fp8,
flux2_dev_cow_mistral_iq4_xs,
flux2_dev_cow_mistral_q4,
Expand Down
2 changes: 1 addition & 1 deletion invokeai/frontend/web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -67212,7 +67212,7 @@
"variant"
],
"title": "MistralEncoder_Checkpoint_Config",
"description": "Configuration for a single-file Mistral text encoder (safetensors).\n\nAccepts both 30-layer cow (Comfy-Org bf16/fp8/fp4) and 40-layer Mistral Small 3\n(BFL canonical / upstream Mistral 3.x single-files). The loader uses the\ndetected variant to decide whether to keep or strip the final RMSNorm."
"description": "Configuration for a single-file Mistral text encoder (safetensors).\n\nAccepts both 30-layer cow (Comfy-Org bf16/fp8) and 40-layer Mistral Small 3\n(BFL canonical / upstream Mistral 3.x single-files). The loader uses the\ndetected variant to decide whether to keep or strip the final RMSNorm."
},
"MistralEncoder_Diffusers_Config": {
"properties": {
Expand Down
1 change: 1 addition & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,7 @@
"duplicateWanTransformer": "The same model is selected as both Transformer and Transformer (Low Noise). An A14B expert pair needs two different models.",
"noZImageVaeSourceSelected": "No VAE source: Select VAE (FLUX) or Qwen3 Source model",
"noZImageQwen3EncoderSourceSelected": "No Qwen3 Encoder source: Select Qwen3 Encoder or Qwen3 Source model",
"zImageQwen3EncoderIncompatible": "Selected Qwen3 Encoder is incompatible with Z-Image: select a Qwen3 4B encoder",
"noKrea2VaeModelSelected": "Non-diffusers Krea-2: select a VAE in Advanced settings",
"noKrea2Qwen3VlEncoderModelSelected": "Non-diffusers Krea-2: select a Qwen3-VL Encoder in Advanced settings",
"krea2RebalanceWeightsInvalid": "Krea-2 Conditioning Rebalance weights must be exactly 12 finite comma-separated numbers",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ const mockFluxVAE = {
format: 'checkpoint' as const,
};

const mockZImageQwen3Encoder = {
key: 'zimage-qwen3-4b-key',
hash: 'zimage-qwen3-4b-hash',
name: 'Z-Image Qwen3 4B Encoder',
base: 'any' as const,
type: 'qwen3_encoder' as const,
variant: 'qwen3_4b' as const,
};

const mockAnimaMainModel = {
key: 'anima-main-key',
hash: 'anima-main-hash',
Expand Down Expand Up @@ -153,15 +162,15 @@ const mockSelectQwen3VLEncoderModels = vi.fn((_state: unknown) => [mockKrea2Qwen
const mockSelectZImageDiffusersModels = vi.fn((_state: unknown) => [] as unknown[]);
// Z-Image borrows the FLUX.1 VAE pool - flux2 VAEs are deliberately not part of it.
const mockSelectFlux1VAEModels = vi.fn((_state: unknown) => [] as unknown[]);
const mockSelectQwen3EncoderModels = vi.fn((_state: unknown) => [] as unknown[]);
const mockSelectZImageQwen3EncoderModels = vi.fn((_state: unknown) => [] as unknown[]);

vi.mock('services/api/hooks/modelsByType', () => ({
selectAnimaQwen3EncoderModels: (state: unknown) => mockSelectAnimaQwen3EncoderModels(state),
selectAnimaVAEModels: (state: unknown) => mockSelectAnimaVAEModels(state),
selectAnimaCompatibleVAEModels: (state: unknown) => mockSelectAnimaCompatibleVAEModels(state),
selectQwenImageVAEModels: (state: unknown) => mockSelectQwenImageVAEModels(state),
selectQwen3VLEncoderModels: (state: unknown) => mockSelectQwen3VLEncoderModels(state),
selectQwen3EncoderModels: (state: unknown) => mockSelectQwen3EncoderModels(state),
selectZImageQwen3EncoderModels: (state: unknown) => mockSelectZImageQwen3EncoderModels(state),
selectZImageDiffusersModels: (state: unknown) => mockSelectZImageDiffusersModels(state),
selectFlux1VAEModels: (state: unknown) => mockSelectFlux1VAEModels(state),
selectGlobalRefImageModels: vi.fn(() => []),
Expand Down Expand Up @@ -247,6 +256,7 @@ const paramsSliceActual = (await vi.importActual('features/controlLayers/store/p
animaVaeModelSelected: { type: string };
krea2VaeModelSelected: { type: string };
krea2Qwen3VlEncoderModelSelected: { type: string };
zImageQwen3EncoderModelSelected: { type: string };
zImageQwen3SourceModelSelected: { type: string };
zImageVaeModelSelected: { type: string };
};
Expand All @@ -255,6 +265,7 @@ const {
animaVaeModelSelected,
krea2VaeModelSelected,
krea2Qwen3VlEncoderModelSelected,
zImageQwen3EncoderModelSelected,
zImageQwen3SourceModelSelected,
zImageVaeModelSelected,
} = paramsSliceActual;
Expand Down Expand Up @@ -764,7 +775,7 @@ describe('modelSelected listener - Z-Image VAE defaulting', () => {
mockDispatch.mockClear();
// No diffusers model installed, so the listener falls through to the encoder + VAE branch.
mockSelectZImageDiffusersModels.mockReturnValue([]);
mockSelectQwen3EncoderModels.mockReturnValue([mockAnimaQwen3Encoder]);
mockSelectZImageQwen3EncoderModels.mockReturnValue([mockZImageQwen3Encoder]);
mockSelectFlux1VAEModels.mockReturnValue([mockFluxVAE]);
});

Expand All @@ -778,6 +789,42 @@ describe('modelSelected listener - Z-Image VAE defaulting', () => {
expect(vaeDispatch!.payload).toMatchObject({ key: mockFluxVAE.key, base: 'flux' });
});

// The encoder slot draws from the 4B pool: the general Qwen3 pool also lists Klein 9B's 8B encoder,
// and defaulting to it made the first denoise step fail with a 4096 vs 2560 shape mismatch (#9526).
it('should default the Z-Image encoder slot from the 4B pool', () => {
const state = buildMockState({ model: mockFluxMainModel });
const action = modelSelected(zParameterModel.parse(mockZImageTurboMain));

capturedEffect!(action, { getState: () => state, dispatch: mockDispatch });

const encoderDispatch = dispatched.find(
(a) => a.type === zImageQwen3EncoderModelSelected.type && a.payload !== null
);
// The full identifier: the slot's reducer parses with zModelIdentifierField, which silently drops a
// payload without `hash` and `type` - the slot then stayed empty despite the dispatch.
expect(encoderDispatch!.payload).toEqual({
key: mockZImageQwen3Encoder.key,
hash: mockZImageQwen3Encoder.hash,
name: mockZImageQwen3Encoder.name,
base: mockZImageQwen3Encoder.base,
type: mockZImageQwen3Encoder.type,
});
});

it('should not default the Z-Image encoder slot when the 4B pool is empty', () => {
mockSelectZImageQwen3EncoderModels.mockReturnValue([]);

const state = buildMockState({ model: mockFluxMainModel });
const action = modelSelected(zParameterModel.parse(mockZImageTurboMain));

capturedEffect!(action, { getState: () => state, dispatch: mockDispatch });

const encoderDispatch = dispatched.find(
(a) => a.type === zImageQwen3EncoderModelSelected.type && a.payload !== null
);
expect(encoderDispatch).toBeUndefined();
});

it('should not default the Z-Image VAE slot when the FLUX.1 pool is empty', () => {
mockSelectFlux1VAEModels.mockReturnValue([]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ import {
selectAnimaVAEModels,
selectFlux1VAEModels,
selectGlobalRefImageModels,
selectQwen3EncoderModels,
selectQwen3VLEncoderModels,
selectQwenImageDiffusersModels,
selectQwenImageVAEModels,
Expand All @@ -77,6 +76,7 @@ import {
selectWanT5EncoderModels,
selectWanVAEModels,
selectZImageDiffusersModels,
selectZImageQwen3EncoderModels,
} from 'services/api/hooks/modelsByType';
import type { FLUXKontextModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types';
import {
Expand Down Expand Up @@ -176,7 +176,8 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
} else {
// Fallback: try to set Qwen3 Encoder + VAE
const availableQwen3Encoders = selectQwen3EncoderModels(state);
// 4B encoders only - the 8B one Klein 9B uses is listed too, but Z-Image cannot consume it (#9526).
const availableQwen3Encoders = selectZImageQwen3EncoderModels(state);
// FLUX.1 VAEs only - the Z-Image VAE picker is built from `isFlux1VAEModelConfig` and
// Z-Image cannot use a FLUX.2 VAE, so a wider flux+flux2 pool would default the slot to
// a model the user can neither see in the picker nor generate with.
Expand All @@ -190,8 +191,10 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
dispatch(
zImageQwen3EncoderModelSelected({
key: qwen3Encoder.key,
hash: qwen3Encoder.hash,
name: qwen3Encoder.name,
base: qwen3Encoder.base,
type: qwen3Encoder.type,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,7 @@ const slice = createSlice({
}
state.zImageVaeModel = result.data;
},
zImageQwen3EncoderModelSelected: (
state,
action: PayloadAction<{ key: string; name: string; base: string } | null>
) => {
zImageQwen3EncoderModelSelected: (state, action: PayloadAction<ModelIdentifierField | null>) => {
const result = zParamsState.shape.zImageQwen3EncoderModel.safeParse(action.payload);
if (!result.success) {
return;
Expand Down
15 changes: 15 additions & 0 deletions invokeai/frontend/web/src/features/metadata/parsing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,21 @@ describe('ImageMetadataHandlers — Anima / Z-Image / FLUX.1 recall gating', ()
).rejects.toThrow();
});

// Klein 9B's 8B encoder is 4096 wide; Z-Image's caption embedder takes 2560 and fails at the first
// denoise step (#9526). A workflow-built Z-Image image can still record one.
it('rejects Klein 9Bs 8B encoder', async () => {
currentBase = 'z-image';
nextResolved = largeEncoder('qwen3_8b');
const store = makeStore();

await expect(
ImageMetadataHandlers.ZImageQwen3EncoderModel.parse(
{ model: fakeMain('z-image'), qwen3_encoder: nextResolved },
store
)
).rejects.toThrow();
});

// This handler recalls into the Z-Image slots (and nulls zImageQwen3SourceModel). Anima and FLUX.2
// Klein write the same metadata field, so without the base gate they would clobber those slots.
it.each(['anima', 'flux2'])('rejects when the current base is %s', async (base) => {
Expand Down
13 changes: 7 additions & 6 deletions invokeai/frontend/web/src/features/metadata/parsing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ import {
isFlux1VAEModelConfig,
isFlux2VAEModelConfig,
isQwen3EncoderModelConfig,
isZImageQwen3EncoderModelConfig,
} from 'services/api/types';
import { assert } from 'tsafe';
import z from 'zod';
Expand Down Expand Up @@ -1598,18 +1599,18 @@ const ZImageQwen3EncoderModel: SingleMetadataHandler<ModelIdentifierField> = {
// Check provenance: `qwen3_encoder` is also written by Anima and FLUX.2 Klein, and this handler
// clears `zImageQwen3SourceModel` on recall (review 4966712044).
assertMetadataModelBase(metadata, 'z-image', 'ZImageQwen3EncoderModel');
// The picker's domain (`useQwen3EncoderModels`): the 4B/8B encoders, i.e. everything except Anima's
// 0.6B, whose 1024-wide embeddings Z-Image cannot consume. That split lives in `variant`, so the
// full config is needed - the identifier alone cannot tell the two apart.
// The picker's domain (`useZImageQwen3EncoderModels`): the 4B encoder only. Anima's 0.6B (1024 wide)
// and Klein 9B's 8B (4096 wide) produce embeddings Z-Image cannot consume (#9526). That split lives in
// `variant`, so the full config is needed - the identifier alone cannot tell them apart.
const parsed = await parseModelIdentifierMatching({
raw: getProperty(metadata, 'qwen3_encoder'),
store,
type: 'qwen3_encoder',
isCompatible: isQwen3EncoderModelConfig,
isCompatible: isZImageQwen3EncoderModelConfig,
handlerType: 'ZImageQwen3EncoderModel',
});
// Klein and Z-Image encoders both satisfy isQwen3EncoderModelConfig, so the variant cannot separate
// those two - the currently selected base does.
// Klein 4B and Z-Image share the 4B encoder, so the variant cannot separate those two - the currently
// selected base does.
const base = selectBase(store.getState());
assert(base === 'z-image', 'ZImageQwen3EncoderModel handler only works with Z-Image models');
return Promise.resolve(parsed);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type ModelEditFormValues = UpdateModelBody & {
};

const stringFieldOptions = {
validate: (value?: string | null) => (value && value.trim().length > 3) || 'Must be at least 3 characters',
validate: (value?: string | null) => (value && value.trim().length >= 3) || 'Must be at least 3 characters',
};

export const ModelEdit = memo(({ modelConfig }: Props) => {
Expand Down Expand Up @@ -142,7 +142,7 @@ export const ModelEdit = memo(({ modelConfig }: Props) => {
leftIcon={<PiCheckBold />}
onClick={form.handleSubmit(onSubmit)}
isLoading={isSubmitting}
isDisabled={Boolean(Object.keys(form.formState.errors).length)}
isDisabled={!form.formState.isValid}
>
{t('common.save')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
import { type ModelIdentifierField, zModelIdentifierField } from 'features/nodes/types/common';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useFlux1VAEModels, useQwen3EncoderModels, useZImageDiffusersModels } from 'services/api/hooks/modelsByType';
import {
useFlux1VAEModels,
useZImageDiffusersModels,
useZImageQwen3EncoderModels,
} from 'services/api/hooks/modelsByType';
import type { MainModelConfig, Qwen3EncoderModelConfig, VAEModelConfig } from 'services/api/types';

/**
Expand Down Expand Up @@ -70,7 +74,7 @@ const ParamZImageQwen3EncoderModelSelect = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const zImageQwen3EncoderModel = useAppSelector(selectZImageQwen3EncoderModel);
const [modelConfigs, { isLoading }] = useQwen3EncoderModels();
const [modelConfigs, { isLoading }] = useZImageQwen3EncoderModels();

const _onChange = useCallback(
(model: Qwen3EncoderModelConfig | null) => {
Expand Down
Loading
Loading