Skip to content

Studio: add a Disable Vision toggle to a model's Advanced Settings - #9383

Merged
danielhanchen merged 13 commits into
mainfrom
studio-vision-switch
Aug 20, 2026
Merged

danielhanchen merged 13 commits into
mainfrom
studio-vision-switch

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Adds a Disable Vision toggle to a model's Advanced Settings: load a vision GGUF as text-only, with no projector on the GPU and none on the CPU either. The projector's VRAM is left for the model and the context, which on a card that cannot hold both is the difference between a usable context and none.

Split out of #9063 at review request so the placement accounting and this can be read separately. Stacked on #9063 and targeted at its branch, so the diff here is only the switch; merge that one first.

What it does

The switch is per-model config, so it persists beside the other Advanced Settings, changes the config signature (flipping it is a real reload rather than a dedupe), and survives a build that has never heard of it in both directions.

Where it deliberately does nothing

Most of the work is in the cases where suppressing a projector would be wrong:

  • An audio-only projector stays. ultravox, Voxtral and Qwen3-ASR declare an audio encoder and no vision tower, so dropping one removes the model's audio input and frees no image VRAM. A projector serving both modalities (Qwen2.5-Omni) cannot be half-loaded, so audio goes with vision there and the log says so rather than leaving it a mystery.
  • vision_disabled_by_user is recorded where the projector is actually dropped, not re-derived afterwards. A missing file, a family mismatch, or an advanced argument that drops the projector are not the switch, and blaming it there tells the user to turn Vision back on when the same projector will just be rejected again.
  • The diffusion runner ignores the switch, so the resident-config comparison skips it there instead of tearing down a runtime that was already the one asked for.
  • The projector download stays behind the cache interlock: the load still fetches whatever the repo ships, switch or no switch.
  • The rollback seeds the control from the restored model's loaded value. applyPerModelConfigToRuntime writes the target's setting into the store before the snapshot is taken, so seeding from the snapshot would leave Vision reading "off" over a running projector and arm the next Apply to switch it off for real.

Verification

  • Backend: test_mmproj_placement_policy, test_gpu_memory_mode, test_llama_cpp_mmproj_fallback, test_inference_status_loaded_gguf, test_metal_paravirtual_guard — 470 passed.
  • Frontend: 4114 passed.
  • The full backend suite and cross-platform CI were green on the combined branch before the split; both halves get their own runs.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: fd350f3050

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 20, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 20, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 20, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 20, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 20, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 20, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

Review rounds are settled, so here is the record after clearing the resolved chatter.

What this PR does. Adds the per-model Vision on/off switch: the toggle in Advanced Settings, its per-model config and rollback behaviour, the load/status plumbing that reports what the server was actually loaded with, and the composer copy that explains why images are unavailable when they are.

Three rules the review settled, all of which had to be enforced in more than one place:

  1. The switch turns VISION off, and a projector is not always a vision tower. ultravox, Voxtral and Qwen3-ASR declare an audio encoder and no image tower, so suppressing one takes audio away and hands back no image VRAM. That exception now covers the resolved projector, an inherited LLAMA_ARG_MMPROJ, the capability probe that reports what the child has, and the --no-mmproj-auto gate, all off one predicate.
  2. An unconfigured model gets the per-model DEFAULT, not the outgoing model's setting. It is not tensorParallel, which deliberately stands across models.
  3. Nothing blames the switch for images that turning it back on cannot restore: a text-only GGUF with a stale toggle, an audio-only projector, and an advanced argument that drops the projector all fall through to the generic message.

Validation. 4117 frontend tests, both tsc projects clean. Full backend suite: 443 failure/error names against a 432-name current-main baseline; eight of the extra names pass on rerun, and the remaining three (test_tool_output_streaming.py grandchild-kill tests) reproduce identically on the base branch, which does not touch that code, so they are this host and not this PR. Cross-platform CI on a staging repo: ubuntu-latest and macos-14 green, Playwright and windows still running as of this comment.

Stacked on #9063, which carries the projector placement accounting.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94d688deb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +413 to +415
(prevState.loadedVisionDisabledByUser === null ||
hydratingExistingModel) && {
disableVision: status.disable_vision,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resync Vision after same-model reloads

When another tab or API client reloads the same model with a different disable_vision value, hydratingExistingModel remains false and loadedVisionDisabledByUser is already non-null, so this block leaves the editable disableVision value unchanged. The following blocks nevertheless update loadedDisableVision and the live image-gating state, leaving Advanced Settings showing the opposite of the running projector state; a later Apply or unrelated reload can then send the stale value and undo the external change. Preserve a genuinely pending local edit, but otherwise reseed this control when the incoming value differs from the previous loaded baseline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the divergence is mine rather than inherited. tensorParallel right above sets the control and its baseline in the SAME guarded spread, so the two cannot come apart. I split them deliberately -- loadedDisableVision has to track the running server or a failed switch rolls back to whatever the last seed happened to see -- and did not carry the consequence through: the baseline and loadedVisionDisabledByUser follow every poll while the control was seeded once.

Fixed in b6133b9. The rule moves out of the spread into shouldSeedVisionSwitch, next to resolveChatTemplateSeed, which is the pattern this file already uses for exactly this kind of guard:

if (previous.loadedVisionDisabledByUser === null) return true;   // never seeded
if (hydratingExistingModel) return true;                          // model/variant changed
if (previous.loadedDisableVision === null) return false;          // no baseline to compare
if (previous.loadedDisableVision === incoming) return false;      // nothing moved
return previous.disableVision === previous.loadedDisableVision;   // no pending local edit

Your caveat is the last line and it is the load-bearing one. "No pending edit" is measured against the OLD baseline, not against the incoming value, so a control the user moved and has not applied survives an external reload; only a control still sitting on its baseline gets resynced.

Eight cases pinned in a new vision-switch-seed.test.ts, plus a source-level check that the applier still routes through the resolver, since re-inlining the old guard would restore the divergence without failing any of the case tests. The old vision-toggle-gating.test.ts assertion pinned that inlined regex and is rewritten rather than dropped.

Three mutants. Two died immediately: never resyncing, and resyncing over a pending edit. The third -- deleting the loadedDisableVision === incoming early return -- survived, and it deserves the note: it is behaviourally equivalent through this call site, since the extra write puts back the value already there. I killed it by asserting the resolvers answer for that case rather than a store effect, on the grounds that the contract is what a future caller branches on. 4125 frontend tests pass, both tsc runs exit 0.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b6133b9d7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +6955 to +6956
if not _dv_opens_projector:
_sized_attrs = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Charge retained inherited audio projectors

When a local GGUF is loaded with disable_vision=true while LLAMA_ARG_MMPROJ names an audio-only projector, the loader deliberately preserves that environment projector, but this estimator clears the projector charge based only on config.gguf_mmproj_file. In Auto GPU mode the inherited projector remains GPU-offloaded, so the training coexistence guard can approve an under-budgeted chat load and OOM the running training job. Include the retained environment projector in the estimate, or scrub it so the estimator and launch agree.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is the direct consequence of the previous fix: I taught the loader to keep an inherited audio-only projector and left the estimator reading config.gguf_mmproj_file alone, so a GPU-resident projector this config never names was billed nowhere. Under-charging here costs someone else their training run, which is the worst direction this guard can be wrong in.

Fixed in e26d42b. The estimator now charges LLAMA_ARG_MMPROJ when the launch will really open it, and exactly two things stop it: Studios own --mmproj, which overrides the env (argv after set_env) so one file loads rather than two, and the vision switch, asked through the loaders own _mmproj_env_is_audio_only so the two cannot answer differently.

Writing the tests turned up a second thing worth flagging, because my first version was wrong. I had also gated the charge on extra_args_disable_mmproj, and that is not a stopper: --no-mmproj / --no-mmproj-auto set params.no_mmproj, which suppresses Studios own resolution and the HF auto-download, but server-context.cpp:958 gates the load on a non-empty mmproj.path and never reads the field. So an inherited path loads straight through the opt-out. Same finding as the --no-mmproj-auto thread above, applied consistently this time. That clause is gone and a test pins its absence.

One deliberate widening to call out: this also charges an inherited projector when Vision is ON, which nothing charged before. That case was already under-budgeted for the same reason, it just was not what you reported. It can refuse a chat load that previously squeezed through, which is the safe direction for a guard whose job is protecting a running training job.

The charge is added to the local branchs return rather than to total_bytes, since that value decides local-vs-remote and must stay the main weights verdict. An inherited local projector alongside a REMOTE repo is still charged nowhere; that is unchanged and pre-existing, and I have left it rather than widen this further.

Four mutants, all killed: never charging, charging alongside Studios own, ignoring the audio-only rule, and re-adding the extras opt-out. 725 tests across the placement, batch-sizing, training-coexistence, gpu-memory, mmproj-fallback and server-args suites pass. The full backend suite on the previous head was clean at 432 names against a 432-name main baseline.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 20, 2026
Base automatically changed from studio-mmproj-fit to main August 20, 2026 15:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e26d42be79

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/routes/inference.py Outdated
_env_mmproj = (os.environ.get("LLAMA_ARG_MMPROJ") or "").strip()
if (
_env_mmproj
and not getattr(config, "gguf_mmproj_file", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Charge the inherited projector after suppressing the configured one

When disable_vision=true, the configured projector is image-capable, and LLAMA_ARG_MMPROJ names an audio-only projector, the loader suppresses the configured path but deliberately retains the inherited audio projector; this new gate nevertheless skips the environment charge merely because config.gguf_mmproj_file is populated. Auto GPU admission therefore still underestimates VRAM and can approve a chat load that OOMs a concurrent training job. Fresh evidence in this revision is the added and not getattr(config, "gguf_mmproj_file", None) condition, which fails to account for the configured projector no longer winning once the switch removes its argv entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is a second, wider version of the same mistake. I gated the environment charge on config.gguf_mmproj_file being empty, which quietly assumes Studio's --mmproj always reaches the command line. argv beats the environment only when there IS argv.

Two things empty it while leaving the inherited path in place:

  • the switch dropping an image-capable configured projector, which is exactly the combination you describe, and
  • extras naming --no-mmproj, where llama_cpp.py skips the resolve entirely.

The second one I had actually argued for in my previous reply and then failed to apply: I said the opt-out does not unload an inherited path, which is true, but I did not notice that it also means Studio emits nothing of its own, so the inherited path wins there too.

Fixed in 0d984ee. The gate now asks what Studio emits rather than what the config names, and it reads the same answer the configured projector's own charge is built from, so the two cannot disagree about which single projector loads:

_studio_mmproj_on_argv = bool(
    getattr(config, "gguf_mmproj_file", None) and _dv_opens_projector
)

_dv_opens_projector is now bound before the switch rather than inside it, and the extras opt-out sets it False and clears _sized_attrs in the same place. That second part is a behaviour change beyond your report and worth naming: with --no-mmproj in the extras, the configured projector was being charged even though it never loads. It is now dropped. That is a reduction in the estimate, which is the risky direction for this guard, and I made it anyway because the file provably does not load -- charging it was the estimator disagreeing with the launch, which is the whole defect here.

Four mutants, all killed: restoring the old config.gguf_mmproj_file gate, assuming argv is always present, making the extras opt-out stop emptying the argv, and letting it empty the argv while keeping the charge. Two new tests, one per route to the empty command line, both measured against a baseline taken with the environment variable unset -- my first attempt compared against a baseline that itself charged the inherited projector and read a difference of zero.

813 tests across the placement, batch-sizing, training-coexistence, gpu-memory, mmproj-fallback, server-args and Metal suites pass.

Separately: this branch is now rebased onto main after #9063 was squash-merged, so the diff here is the Vision switch alone. I verified the rebase left no placement code behind, _shared_pool_mmproj, _mm_budgeted_gpus, _MMPROJ_FIT_FLOOR_CTX and _apple_model_size_fit all appear zero times in this PR's diff and are present in main.

Loads a vision GGUF as text-only: no projector on the GPU and none on the CPU
either, leaving the projector's VRAM for the model and the context. For someone
who wants a vision model's text quality on a card that cannot hold both, that is
the difference between a usable context and none.

The switch is per-model config, so it persists beside the other Advanced
Settings, changes the config signature (a flip is a real reload, not a dedupe),
and survives a build that has never heard of it in both directions.

Care went into the cases where the switch must NOT act:

- An audio-only projector stays. ultravox, Voxtral and Qwen3-ASR declare an audio
  encoder and no vision tower, so suppressing one removes the model's audio input
  and frees no image VRAM. A projector serving both modalities (Qwen2.5-Omni)
  cannot be half-loaded, so audio goes with vision there and the log says so.
- vision_disabled_by_user is recorded where the projector is actually dropped,
  not re-derived. A missing file, a family mismatch or an advanced argument that
  drops the projector are not the switch, and blaming it there tells the user to
  turn Vision back on when the same projector will just be rejected again.
- The diffusion runner ignores the switch, so the resident-config comparison
  skips it there rather than tearing down a runtime that was already the one
  asked for.
- The projector download stays behind the cache interlock: the load still fetches
  what the repo ships, switch or no switch.
- The rollback seeds the control from the restored model's loaded value.
  applyPerModelConfigToRuntime writes the target's setting into the store before
  the snapshot is taken, so the snapshot would leave Vision reading off over a
  running projector and arm the next Apply to switch it off for real.

Stacked on the placement accounting, which this threads disable_vision into.
Split out of #9063 so each half can be reviewed on its own.
…last model's

Vision is per-model config with a default of on, but a switch to a model that
saved no config fell back to the outgoing model's setting on both load paths.
So a model saved with Vision off poisoned the next unconfigured model, which
loaded text-only.

Silently, too: the dedupe comparison already builds its view of an unconfigured
switch out of DEFAULT_PER_MODEL_CONFIG, so the two halves disagreed about the
same load, and the resetsPerModelSettings block cannot repair it because the
value is captured before that block runs.

This is where the toggle parts company with tensorParallel, which it was
modelled on: tensorParallel is deliberately standing across models, per the
comment on the comparison, while Vision belongs to the model.

Compare mode had the same shape through fallbackDisableVision, now removed
rather than left unused.
withoutUnsupportedDiffusionSettings forces disableVision back to false
for a diffusion model and the diffusion runner never reads it, so the
row rendered as an actionable switch that flipped back under the pointer
and changed nothing if it had not. Gated on !isDiffusion, like the batch
rows above it.
cpu_offload has three producers, not two: the fit-probe prediction, a GPU
allocation failure at startup, and a bare signal crash with no
non-projector diagnostic. Naming VRAM as the cause is wrong for the two
crash routes and sends someone whose GPU runtime is broken off to cut
context and offload layers. The message now names the outcome only.
The resolved path already has this exception: ultravox, Voxtral and
Qwen3-ASR declare an audio encoder and no vision, so suppressing one
takes the model's audio input away and gives no image VRAM back. An
inherited LLAMA_ARG_MMPROJ got no such reading and was cleared outright.
The capability probe moves with it, off the same predicate, so it cannot
describe a child the scrub did not build. LLAMA_ARG_MMPROJ_URL still
goes unconditionally: there is no downloaded file to classify.
pre-commit-ci Bot and others added 5 commits August 20, 2026 15:28
The gate excluded the resolved audio-only case (launch_mmproj_path stays
set) but not the inherited one, which keeps nothing this gate can see and
needs the same predicate the env scrub used. The flag does not unload
either: server-context.cpp gates the load on a non-empty mmproj.path and
never reads no_mmproj, which only suppresses the HF auto-download and the
router's capability report. Advertising a model text-only while loading
its encoder is still wrong on its own.
…lsewhere

The loaded baseline and the image gate follow every poll, but the control
was seeded once. Another tab or an API client reloading the same model
with the opposite setting therefore left Advanced Settings showing the
opposite of the running projector, and armed the next Apply to undo the
external change. The rule moves into shouldSeedVisionSwitch, next to the
chat-template resolver: reseed when the baseline moved, unless the
control holds an unapplied local edit.
The loader now keeps an audio-only LLAMA_ARG_MMPROJ when Vision is off,
but the coexistence estimator cleared the projector charge from
config.gguf_mmproj_file alone, so a GPU-resident projector this config
never names was billed nowhere and the guard could admit a chat load that
OOMs a running training job.

Two things stop the charge and only two: Studio's own --mmproj, which
overrides the env and means one file loads rather than two, and the
vision switch, asked through the loader's own audio-only helper. Not the
extras opt-out: --no-mmproj sets params.no_mmproj, which server-context
never reads when deciding to load a non-empty mmproj.path.
danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dab4fad428

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +18374 to +18375
if disable_vision and not launch_mmproj_path and not _dv_env_mmproj_kept:
cmd.append("--no-mmproj-auto")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-evaluate the inherited projector after paravirtual scrubbing

On a virtualized-Metal load with Vision disabled, an audio-only LLAMA_ARG_MMPROJ, and a remembered --mmproj-auto, _dv_env_mmproj_kept is true here, so the disabling override is omitted; however, the later _paravirtual_cpu_forced block unconditionally removes that environment path. The surviving --mmproj-auto can then rediscover an adjacent image projector, even though the user requested Vision off, while the capability probe still describes the removed audio-only file. Base this decision on the environment after the paravirtual scrub, or append the override after that scrub.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on all three counts, and the root cause is one I introduced: _dv_env_mmproj_kept is computed from os.environ before the paravirtual block, so it describes an environment the launch does not end up with.

if _paravirtual_cpu_forced: pops both projector vars unconditionally and runs after the switch's block, so the audio-only file the switch kept is gone by launch. Two consequences, both the ones you name: the capability probe still reported an audio encoder the child does not have, and the --no-mmproj-auto override was skipped, leaving a remembered --mmproj-auto free to rediscover an adjacent image projector on a load the user asked to be text-only.

Fixed in afe47cf by folding the later scrub into the predicate rather than re-deriving it downstream:

_dv_env_mmproj_kept = (
    disable_vision
    and not _paravirtual_cpu_forced
    and _mmproj_env_is_audio_only(os.environ.get("LLAMA_ARG_MMPROJ"))
)

_paravirtual_cpu_forced is settled well before the probe, so one predicate now answers for the probe, the argv override and the scrub together, which is the property that was missing rather than any one of the three symptoms.

New test drives a virtualised device with --mmproj-auto in the extras and an audio-only inherited projector, and asserts all three: the var is gone, --no-mmproj-auto is emitted, and _mmproj_has_audio is False. Mutant killed by dropping the not _paravirtual_cpu_forced clause.

…configured

argv beats the environment only when there IS argv. Two things empty the
command line while leaving an inherited LLAMA_ARG_MMPROJ in place: the
vision switch dropping an image-capable configured projector, and extras
naming --no-mmproj, which makes llama_cpp.py skip the resolve entirely.
In both the inherited projector loads unopposed and was charged nowhere.

The gate now asks what Studio emits rather than what the config names,
off the same answer the configured projector's own charge is built from,
so the two cannot disagree about which single projector loads.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d984ee3a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

else:
self._hf_variant = None
self._is_vision = effective_is_vision
self._disable_vision = bool(disable_vision)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record Vision state during Vulkan CPU recovery

When an automatic Vulkan launch crashes but the CPU replay succeeds, _apply_cpu_fallback_state returns success before reaching this assignment, so _disable_vision and _vision_disabled_by_user retain their previous/default values even though the recovered child was launched with Vision disabled. The load/status response then reports the wrong setting, causing the frontend to flip the control back on and making an unchanged disable_vision=true request fail runtime matching and reload the model; copy both Vision fields into the CPU-fallback state before its early return.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. _apply_cpu_fallback_state is the last thing the recovery runs and its caller return Trues immediately after, before the load's own assignment of these two, so a recovered child reported the previous load's Vision state. That is worse now than it was a week ago: the status reseed resolver added earlier in this PR treats a moved baseline as an external reload, so a wrong disable_vision in the response is exactly the shape that flips the control back on.

Fixed in afe47cf. The helper takes both fields and records them next to _is_vision and _mmproj_has_audio, and both call sites pass them, using the same expression the load's own assignment uses since the replay carries the same switch and the same dropped projector.

Two tests. One drives the helper directly and asserts the two fields land while the rest of the recovery state still does. The other pins that both call sites pass them: only one is on the common path, so a keyword added to one and not the other is a silent half-fix, and the second needs a crash inside a replay this harness cannot stage. That one had to be tightened after it first matched the load's own self._vision_disabled_by_user = bool( and counted three uses where there are two.

Comment on lines +18580 to +18581
if not _mmproj_env_is_audio_only(env.get("LLAMA_ARG_MMPROJ")):
env.pop("LLAMA_ARG_MMPROJ", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark inherited projector suppressions as user-disabled

When a vision model has no resolved Studio projector but a readable image-capable LLAMA_ARG_MMPROJ supplies one, this branch removes that projector because the user disabled Vision, yet _dv_dropped_image_projector remains false because it is only set in the configured-projector branch. Consequently vision_disabled_by_user is reported false and the composer gives the generic missing/invalid-mmproj guidance even though turning the Vision switch back on restores the inherited projector; record this image-capable environment suppression as user-requested too.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. _dv_dropped_image_projector was only ever set in the configured-projector branch, so an inherited image-capable projector that the switch scrubbed reported vision_disabled_by_user = false and the composer fell through to the generic missing/invalid-mmproj guidance -- pointing the user away from the one control that would restore it.

Fixed in afe47cf: the env scrub records the same signal the resolved path does, at the point the decision is made.

One deliberate narrowing. It is recorded only when the inherited path names a file that exists:

if _dv_env_mmproj and Path(_dv_env_mmproj).is_file():
    _dv_dropped_image_projector = True

A stale path drops nothing, so turning Vision back on changes nothing, and blaming the switch there sends the user to a control that cannot help -- the same reason the resolved path does not blame the switch for a missing file or a family mismatch. Unknown-but-present still reads image-capable, matching the rule the scrub itself just used.

Two tests, one per side, and both mutants died: removing the assignment fails the positive case, and widening it to if True: fails three of the existing "do not blame the switch" tests, which is what stops this becoming a blanket claim. 979 tests across the placement, paravirtual, mmproj-fallback, gpu-memory, Metal, batch-sizing, training-coexistence, server-args and TP suites pass.

pre-commit-ci Bot and others added 2 commits August 20, 2026 15:49
Three places the switch's own decision was read by code that ran later
and disagreed with it.

The virtualised-Metal scrub takes both projector vars unconditionally and
runs after the switch's block, so a kept audio-only file is gone by
launch; the probe still described it and --no-mmproj-auto was skipped,
leaving a remembered --mmproj-auto free to rediscover an image projector.

An inherited image projector dropped by the switch is restored by turning
it back on, so it now records vision_disabled_by_user like the resolved
path does, but only for a file that exists.

And the Vulkan-crash CPU recovery returns before the load assigns those
two fields, so the response described the previous load's Vision state.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 20, 2026
@danielhanchen
danielhanchen merged commit 91dad23 into main Aug 20, 2026
5 of 48 checks passed
@danielhanchen
danielhanchen deleted the studio-vision-switch branch August 20, 2026 16:06

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afe47cfdb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +9247 to +9252
config.is_vision
and not extra_args_disable_mmproj(extra_llama_args)
and _load_keeps_a_projector(
config,
disable_vision = bool(getattr(request, "disable_vision", False)),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Account for retained environment projectors before skipping the arbiter

When Manual GPU Memory uses zero layers with speculation off, Vision is disabled, the configured projector is image-capable, and LLAMA_ARG_MMPROJ names an audio-only projector, the loader suppresses the configured file but deliberately retains the inherited projector. This predicate nevertheless reports no projector because _load_keeps_a_projector inspects only the config, and zero_vram_chat_load does not detect LLAMA_ARG_MMPROJ, so the route skips acquire_for(CHAT) and can launch a GPU-offloaded audio projector alongside the current Images/Video owner. Include the retained environment projector in the zero-VRAM decision.

Useful? React with 👍 / 👎.

Comment on lines +93 to +97
// Before the generic message below, which would otherwise send someone who
// switched Vision off hunting for a vision model with a valid mmproj. The
// model is capable and the projector is fine; the setting is what is in the way.
if (visionDisabledByUser) {
return `Vision is turned off for ${label}. Turn it back on in the model's Advanced Settings to attach images.`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block images when retained audio keeps multimodality true

When Vision suppresses a configured image projector but an inherited audio-only projector is retained, the response legitimately has both has_audio_input=true and vision_disabled_by_user=true. That makes loadedIsMultimodal true, and for the catalog's vision model the earlier branch returns success before reaching this new check, so the attachment picker accepts images even though the running server has only an audio encoder. Distinguish runtime image capability from generic multimodality, or evaluate the truthful Vision-disabled state before allowing the attachment.

Useful? React with 👍 / 👎.

danielhanchen added a commit that referenced this pull request Aug 20, 2026
…every PR (#9414)

The ruff-format-with-kwargs hook rewrites this file, so pre-commit.ci
reports 'files were modified by this hook' and fails. It fails on main
itself, which means it fails on every open pull request whose merge
includes main, whatever that pull request changed: 9400 changes four
TypeScript files and no Python at all, and still fails this check.

Reproduced on a clean worktree at origin/main by running the hook's own
entry point over every Python file it selects. Exactly one file comes
back modified, this one, introduced by #9063 and last touched by #9383.

The change is formatting only: two signatures rejoined onto one line, one
call rejoined, and one blank line after an import removed.

Co-authored-by: danielhanchen <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant