fix: remove FP4 Mistral starter, fix model rename validation, limit Z-Image to Qwen3 4B encoders - #9587
fix: remove FP4 Mistral starter, fix model rename validation, limit Z-Image to Qwen3 4B encoders#9587Pfannkuchensack wants to merge 8 commits into
Conversation
The loader has no FP4 support, so the 12.3 GB download failed with a shape mismatch at the first generation. Also drop FP4 from the recommended encoders in the 40-layer Mistral warning. Closes invoke-ai#9565
… clearing the name The name validator required more than 3 characters while its message says "at least 3". Save was gated on the error count, which could stay stale after clearing and retyping the name; gate it on isValid instead. Adopted from invoke-ai#9419. Closes invoke-ai#9364 Co-authored-by: leepokai <109857817+leepokai@users.noreply.github.com>
Z-Image consumes 2560-wide embeddings, but the encoder picker, the auto-default on model switch and metadata recall all accepted Klein 9B's 8B encoder (4096 wide), which fails at the first denoise step. Narrow all three to qwen3_4b, and add a readiness reason for a slot that still holds an 8B encoder from before this change - the picker no longer shows it, so the slot would otherwise look valid. Closes invoke-ai#9526
The auto-default on switching to Z-Image dispatched only key, name and base. The reducer parses with zModelIdentifierField, which requires hash and type, so it dropped the payload and the encoder slot stayed empty. Send the full identifier like the VAE and Anima defaults do, and type the reducer's payload as ModelIdentifierField so the compiler catches this shape.
lstein
left a comment
There was a problem hiding this comment.
Adversarial review at f13282dbbb. I tried to break each of the three fixes; all three are correct in the paths they name, and the tests they add are load-bearing. One gap in the new readiness check lets the exact failure from #9526 through, so I am requesting changes for that alone. The fix is a two-line move.
Verified locally on the branch: tsc --noEmit, eslint, prettier --check clean; vitest 226/226 on the three touched suites; ruff check/format clean on the two Python files; the starter list imports (195 entries, no FP4 Mistral). knip reports 7 unused exported types, all present on main and untouched by this diff.
Blocker
B1. The incompatibility check is skipped exactly when the loader still uses the stale encoder — self-contained SDNQ pipeline mains.
readiness.ts:542 and :1302 sit inside if (!mainIsSelfContainedPipeline). But buildZImageGraph.ts:102 passes qwen3_encoder_model: zImageQwen3EncoderModel ?? undefined unconditionally, and ZImageModelLoaderInvocation resolves the encoder as standalone slot → Qwen3 Source → self-contained main (z_image_model_loader.py:122-134). So a populated slot always wins over the pipeline's own encoder.
Triggering sequence:
- Pre-PR state: Z-Image slot holds Klein 9B's 8B encoder, VAE slot holds a FLUX.1 VAE.
- Upgrade; select an SDNQ Z-Image pipeline main.
modelSelected.ts:145seeszImageVaeModel && zImageQwen3EncoderModel→hasValidConfig→ leaves both slots alone. - Readiness:
mainIsSelfContainedPipelineis true → the whole block, including the newelse if, is skipped. Invoke is enabled. - Loader takes the 8B slot ahead of the pipeline's 4B encoder →
4096 vs 2560at the first denoise step — the same error as #9526, from the one class of main the PR's new safety net does not cover.
I confirmed this with a probe in readiness.test.ts (fails on the branch):
it('errors when the standalone encoder is an 8B encoder even with a self-contained SDNQ main', () => {
const reasons = getReasonsWhyCannotEnqueueGenerateTab(
buildZImageTabArg({
model: zImageSdnqPipelineModel,
zImageQwen3EncoderModel: { key: 'enc' },
zImageQwen3EncoderConfig: { key: 'enc', type: 'qwen3_encoder', variant: 'qwen3_8b' } as AnyModelConfig,
})
);
expect(hasZImageQwen3IncompatibleReason(reasons)).toBe(true);
});Fix: hoist the check out of the !mainIsSelfContainedPipeline branch in both tabs, so it runs whenever zImageQwen3EncoderConfig resolves — the slot is consulted by the loader regardless of the main's format. Please add the probe (and its canvas twin) so the hole stays closed.
Non-blocking
N1. First-load race: the new reason can be missing until something else changes. useReadinessWatcher (readiness.ts:249-289) does not depend on the model-configs query. On a fresh page load params rehydrates before getModelConfigs resolves, so the first debouncedUpdateReasons run sees query.data === undefined, selectZImageQwen3EncoderConfig returns null, and no reason is pushed. It only appears after the next dependency change (socket connect, any param edit). wanWiredConfigs has the same exposure, so this is a pre-existing pattern rather than something this PR introduced — worth a follow-up that makes the resolved configs a watcher dependency.
N2. FP4 is still "accepted" in three docstrings and by the probe. mistral_encoder.py:7 and :348 (loader) and configs/mistral_encoder.py:269 still advertise Comfy-Org fp4. _drop_quantization_metadata does weight * scale on the packed uint8 nibbles, which is where the shape mismatch in #9565 comes from — it was never FP4 dequantization. Removing the starter stops the 12 GB download, but a user who installs the file by hand still gets the same crash at first generation. Suggest fixing the docstrings here and, as a follow-up, rejecting *_fp4_mixed at probe or load with a clear message.
N3. isValid runs full-form validation on mount, and _updateValid does not populate errors. Two consequences worth knowing: a model whose current name is under 3 characters, or an external model whose provider_id/provider_model_id is under 3 characters, now opens with Save disabled and no visible error until the offending field is touched. Previously Save was enabled until the user edited the name. Rare enough to leave as-is; noting so the change in behaviour is deliberate. I reproduced with react-hook-form 7.69 that !isValid tracks '' → abcdefgh → abc → ab correctly (disabled/enabled/enabled/disabled), so the new gate does fix the "delete everything then retype" path in #9364.
Attacks that did not land
- Guard correctness.
variantis derived fromhidden_sizein every Qwen3 config class (qwen3_encoder.py:134-190,:364-382,:638-660), sovariant === 'qwen3_4b'is exactly "2560 wide". The field is required on all four config classes; no installed encoder can lack it. - Loader vs readiness precedence. When both the standalone slot and Qwen3 Source are set, the loader uses the slot (
:122), so flagging the slot is not a false positive. - Payload-type change on
zImageQwen3EncoderModelSelected. Every dispatch site (parsing.tsx:1621,:1705, the picker, the listener) already sends a fullModelIdentifierFieldornull;tscagrees. The listener test'stoEqualon the five identifier fields is what catches the old dropped-payload bug. - Metadata recall. The Klein handler (
parsing.tsx:2006-2011) keepsisQwen3EncoderModelConfig, so Klein 9B recall is unaffected; the Z-Image handler's base gate still separates Klein 4B from Z-Image. - Starter removal. No other reference to
flux2_dev_comfy_mistral_fp4anywhere in the tree (bundles included). - Klein 4B + 8B encoder is the mirror of this bug in
ParamFlux2KleinModelSelect, but it is out of scope for the linked issues.
Review follow-up. The new Qwen3 encoder compatibility check sat inside the `!mainIsSelfContainedPipeline` branch in both tabs, but ZImageModelLoaderInvocation resolves the encoder as standalone slot -> Qwen3 Source -> self-contained main, and buildZImageGraph passes the slot unconditionally. A populated slot therefore wins even when the main ships its own encoder, so the check skipped exactly the class of main it is meant to protect: selecting an SDNQ Z-Image pipeline with a leftover Klein 9B 8B encoder in the slot left Invoke enabled and failed at the first denoise step with 4096 vs 2560 - the invoke-ai#9526 crash. The check now runs whenever zImageQwen3EncoderConfig resolves, in both tabs. It cannot double-report: a resolved config means the slot is set, so the "no encoder source" reason does not fire alongside it. Five tests cover it, including the canvas twin; the two self-contained-SDNQ cases fail without this change while the 4B and GGUF cases pass either way, so the fix is not over-correcting. Also drops FP4 from the three Mistral encoder docstrings that still advertised it. _drop_quantization_metadata multiplies the packed uint8 by its scale, which is correct for FP8 and wrong for FP4, where a byte holds two nibbles that must be unpacked first - that is where the shape mismatch in invoke-ai#9565 comes from. Rejecting `*_fp4_mixed` at probe or load stays a follow-up.
The FP4 correction in the previous commit edited the class docstring of MistralEncoder_Checkpoint_Config, which pydantic publishes as the schema description, so schema.ts went stale and typegen-checks failed on "compare files". Also moves the four-line explanation of why FP4 cannot be dequantized out of that docstring and into a comment above the class. It is a note about packed nibbles for whoever picks up the follow-up, not something every API client should receive as a field description - and keeping it out means the regenerated schema is a one-line change instead of five.
Companion to the schema.ts regeneration. openapi.json is checked in too and openapi-checks regenerates and diffs it separately, so the same one-line description change had to land in both artifacts.
Summary
Three small, independent fixes, one commit each.
1. Remove the FLUX.2 [dev] FP4 Mistral encoder starter model (#9565)
The Mistral loader has no FP4 support. Users downloaded 12.3 GB and got a shape mismatch at the first generation. This removes the starter entry and drops FP4 from the recommended encoders in the loader's 40-layer Mistral warning. The GGUF and FP8/BF16 variants are unaffected.
2. Model rename: accept 3-character names and re-enable Save (#9364)
Adopted from #9419 by @leepokai, who closed it because they could not follow up. The change is unmodified and they are credited as co-author.
length > 3while its message says "at least 3 characters". It now uses>= 3.formState.errors, which could stay stale after clearing and retyping the name. It is now gated on!formState.isValid, the same patternSystemPromptFormuses.3. Only offer Qwen3 4B encoders for Z-Image (#9526)
Z-Image consumes 2560-wide embeddings. Three places also accepted Klein 9B's 8B encoder (4096 wide), which fails at the first denoise step:
useQwen3EncoderModels)selectQwen3EncoderModels)isQwen3EncoderModelConfig)All three now use a new
isZImageQwen3EncoderModelConfigguard (variant === 'qwen3_4b'), with a matching hook and selector.variantis required on all Qwen3 encoder configs, so no installed encoder is hidden by mistake.Users who already have an 8B encoder in the Z-Image slot would see an empty picker while generation still fails. For them, readiness now reports "Selected Qwen3 Encoder is incompatible with Z-Image". The slot's config is resolved in the caller, following the existing
wanWiredConfigspattern. The now unusedselectQwen3EncoderModelsexport is removed. The new i18n key is added toen.jsononly.Related Issues / Discussions
Closes #9565
Closes #9364
Closes #9526
Supersedes #9419
QA Instructions
Automated (run locally):
tsc --noEmit,eslint,prettier --check,knip: cleanvitestonreadiness.test.ts,parsing.test.tsx,modelSelected.test.ts: 226/226 passing. New tests:ZImageQwen3EncoderModelrejects an 8B encoderruff checkandruff format --checkon the two Python files: clean. The starter list imports with no dangling reference.I have not tested these changes manually in the running app. Suggested manual checks:
Merge Plan
No special considerations. The redux state shape is unchanged (no slice or migration changes). A slot that still holds an 8B encoder keeps its value and is only flagged by readiness.
Checklist
What's Newcopy (if doing a release after this PR)