Skip to content

Studio: send with no model loaded only auto-loads on-device models, never downloads the hard-coded default#7379

Open
shimmyshimmer wants to merge 6 commits into
mainfrom
studio-autoload-on-device-only
Open

Studio: send with no model loaded only auto-loads on-device models, never downloads the hard-coded default#7379
shimmyshimmer wants to merge 6 commits into
mainfrom
studio-autoload-on-device-only

Conversation

@shimmyshimmer

@shimmyshimmer shimmyshimmer commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #7374.

Pressing Send in a new chat with no model loaded could report "No downloaded models found" and start downloading unsloth/Qwen3.5-4B-MTP-GGUF (UD-Q4_K_XL) from Hugging Face, even when the user had a perfectly loadable local model on disk. Two underlying problems:

  1. The auto-load path only looked at the managed HF cache lists (listCachedGguf / listCachedModels), so models in the models dir, LM Studio dirs, custom scan folders, and direct local GGUFs were invisible to it. Both requests were also wrapped in .catch(() => []), so an inventory error read as "nothing on device" and fell straight through to the remote download.
  2. The remembered-model store required a Hub quant for every GGUF and the interactive path skipped recording local-path loads entirely, so a local model could never become the remembered model in the first place.

Changes

studio/frontend/src/features/chat/api/chat-adapter.ts

  • autoLoadSmallestModel() is renamed to autoLoadOnDeviceModel() and now resolves candidates in this order: model already active on the inference server (unchanged tryAdoptServerActiveModel), then the last successfully loaded on-device model, then the smallest complete chat-capable on-device model (GGUF group first, then safetensors), merging managed-cache rows with the backend indexed local inventory (/api/hub/local), the same non-React inventory API the unified picker uses.
  • Local rows follow the picker policy: sources models_dir / lmstudio / custom, capabilities.can_chat true, not partial, not hidden infra, no variant requirement, big-endian GGUFs excluded. Rows load via the backend load_id target and record their inventory_id.
  • Candidates appearing in both the cached and local inventories are deduplicated by load target.
  • Inventory fetches now fail closed: a failed lookup surfaces an error and stops automatic selection instead of pretending the device is empty.
  • The hard-coded Qwen download is removed from the send path. With no valid on-device candidate the user gets the actionable "No model loaded: select a model in the top bar, or download one from the Hub, then retry" error. A normal Send can no longer trigger any Hugging Face download.
  • All existing guards are preserved: trust-remote-code and security-review preflight, transformers-upgrade refusal, MAX_AUTO_LOAD_ATTEMPTS, per-model GPU/context/template/speculative settings, and the loadResp.model || loadId runtime identity handling for inactive-cache rows.

studio/frontend/src/features/chat/utils/last-local-model-load.ts

  • Records now carry loadId, inventoryId, and source alongside the existing fields. Parsing stays on the unsloth.last-local-model-load.v1 key and is backward compatible: legacy records default to the managed-cache source and keep working.
  • The non-empty GGUF variant requirement is scoped to managed-cache records; an indexed local GGUF is valid with ggufVariant: null because its load target identifies the file.
  • Only inventory identity is stored, never tokens, native path leases, or approvals.

studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts

  • Successful interactive loads of backend indexed local models (picker source local) are now remembered. Native file-picker selections remain excluded (their access depends on a signed expiring path lease), and other arbitrary paths keep the isLocalModelPath() protection.

studio/frontend/src/features/chat/types/api.ts

  • GgufVariantDetail gains the optional partial flag the backend already sends, so partial variants are skipped during auto-load.

tests/studio/test_model_picker_contracts.py

  • Updated the autoload contract for the rename and added eleven focused source-contract tests: the Qwen repo is unreachable from the send path, the no-model error is actionable, inventory failures are not treated as empty, discovery covers the unified local inventory with the picker filter policy, local rows load by backend target, remembered models resolve across all sources with dedupe, trust guards still block background loads, the remembered-model record supports local sources without secrets, and interactive local loads are remembered without bypassing leases.

Testing

  • cd studio/frontend && npm run typecheck passes.
  • npx biome check on the four changed frontend files: no new diagnostics; pre-existing chat-file diagnostics are reduced (the removed download block carried two of them).
  • pytest tests/studio/test_model_picker_contracts.py -q: 45 passed (34 existing plus 11 new).
  • npm run build succeeds and the rebuilt Studio serves normally.

Behavioral simulations

Beyond the source-contract tests, the fix was exercised end to end in an isolated sandbox (esbuild bundles of the real frontend modules, run under Node with a mocked backend and a controllable localStorage, one fresh process per scenario). The same harness was also built against the pre-PR source (a worktree of main) to prove the reported bug reproduces there.

Pre-PR bug reproduction (all reproduce on main, all impossible after this PR):

  • A loadable local GGUF exists in the models dir, yet local inventory is never queried and a load of unsloth/Qwen3.5-4B-MTP-GGUF is issued.
  • Cached-inventory request failures are treated as an empty cache and fall through to the Qwen load.
  • An empty managed cache triggers the Qwen load.

Post-PR scenarios (40 scenarios plus 14 persistence unit cases, all passing):

  • Remembered model resolution across managed cache, inactive cache via load_id and exact cache_path, models dir, LM Studio, and custom scan folders, including a local GGUF with a null variant.
  • Legacy v1 remembered records, corrupt records, stale records falling back only to on-device candidates, and remembered-variant-deleted fallback.
  • Fail-closed behavior for each of the three inventory endpoints, with per-repo variant-listing failures correctly isolated instead of failing the whole cascade.
  • Filter policy: hidden infra models, partial rows and partial variants, can_chat false, requires_variant rows, big-endian GGUFs, ollama link rows, and hf_cache-source local rows never auto-load.
  • Ordering and budget: GGUF group before safetensors, smallest first, unknown sizes last, cached/local dedupe, MAX_AUTO_LOAD_ATTEMPTS cap, trust and security gated candidates cascade without loading and without any download.
  • OS path shapes: Windows drive paths with case-insensitive matching, UNC paths, POSIX, and unicode paths all resolve.
  • Storage robustness: Safari-private-mode style throwing storage and quota errors never break a load; the persisted record contains identity only, never the HF token or lease material.
  • A global invariant asserts no network call in any scenario ever references the hard-coded Qwen repo or huggingface.co.

Browser and platform compatibility: the changed modules compile cleanly for chrome90, firefox90, safari14, and edge90 esbuild targets, and the full vite production build passes against the project browserslist. The frontend never touches the filesystem directly, so Linux, macOS, and Windows differences reduce to backend-provided path strings, which the path-shape scenarios cover. The Python contract suite was additionally run inside a fresh uv virtualenv: 45 passed.

The autoLoadOnDeviceModel export exists so these simulations can drive the real implementation; it has no runtime effect.

…ard-coded default

Fixes #7374. Pressing Send with no model loaded could miss local models
(models dir, LM Studio, custom scan folders, inactive HF caches) and then
download unsloth/Qwen3.5-4B-MTP-GGUF from Hugging Face without consent.

- autoLoadSmallestModel is now autoLoadOnDeviceModel: adopts the server
  active model, then the remembered on-device model, then the smallest
  complete chat-capable on-device model (GGUF first), across managed HF
  caches and the unified backend local inventory.
- Inventory failures fail closed instead of reading as an empty cache.
- The remote Qwen fallback is removed from the send path; with no valid
  candidate the user is asked to select or explicitly download a model.
- last-local-model-load records loadId/inventoryId/source with backward
  compatible v1 parsing; indexed local loads are remembered while native
  path lease picks stay excluded.
- New source-contract tests in tests/studio/test_model_picker_contracts.py.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@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: 939ea605ad

ℹ️ 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 +1524 to +1526
return [row.load_id, row.id, row.path, row.model_id].some(
(value) => !!value && targets.has(value.toLowerCase()),
);

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 Filter remembered local rows by kind

When a scanned directory produces multiple LocalModelInfo rows that share the same path/load id (for example a folder containing both GGUF and safetensors/adapter weights), this predicate matches only by identifiers and ignores remembered.kind. Interactive local loads currently do not persist an inventoryId, so a remembered non-GGUF load can resolve to the GGUF row (or vice versa), making send-with-no-model attempt the wrong backend/variant or skip the actual last-used model. Include the remembered kind/model format in this match before accepting a row.

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.

Good catch. A folder with both GGUF and safetensors weights does produce two rows sharing the load target, and interactive local loads do not persist an inventoryId. matchesRememberedLocalRow now requires the row format to agree with the remembered kind before identifier matching, covered by test_remembered_local_row_match_requires_kind_agreement and a dual-format simulation. Fixed in c4f182a.

Comment on lines +2032 to +2034
const cascadeLocalRows = localRows.filter(
(row) => row.capabilities?.requires_variant !== true,
);

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 Don't drop local GGUF directories from fallback

When the only on-device models are local GGUF directories from LM Studio/custom/models_dir and there is no remembered model yet, the backend marks those directory rows as requires_variant (see studio/backend/hub/services/models/common.py:528), so this filter removes them before the fallback can list/select a quant. Pressing Send then reports no model loaded even though picker-visible on-device models exist; handle these rows like cached GGUF repos by listing variants, or only skip rows that truly cannot be resolved automatically.

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.

Confirmed against common.py: requires_variant is set for every directory-based GGUF row, so the previous filter dropped all LM Studio and models-dir folders from the fallback. The cascade now resolves the smallest complete quant through the same gguf-variants call the picker card uses (repo id or local path plus local_path), and only skips rows with no resolvable quant. Non-GGUF variant-requiring rows stay excluded since they have no background resolution path. Fixed in c4f182a.

Comment on lines +2055 to +2057
...cascadeLocalRows
.filter((row) => row.model_format !== "gguf")
.map((row) => ({

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 Exclude local adapters from auto-load fallback

For users with a local LoRA/adapter folder in models_dir, LM Studio, or a custom scan folder, the backend advertises adapters as can_chat (studio/backend/hub/services/models/common.py:129), so this non-GGUF fallback can pick an adapter as the smallest on-device row. Loading that adapter path causes the inference loader to treat it as a LoRA adapter and resolve/load its base model; if the base is only a Hugging Face id, pressing Send can still initiate the kind of implicit remote fetch this change is meant to avoid. Filter model_format === "adapter" out of background auto-load unless the base is known to be local/already cached.

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.

Confirmed: adapters are advertised can_chat and the load route auto-detects is_lora from adapter_config.json, so an uncached Hub base could be fetched implicitly. model_format adapter rows are now excluded from background auto-load entirely; adapters remain interactive-only. Fixed in c4f182a.

shimmyshimmer and others added 3 commits July 23, 2026 16:46
Allows behavioral simulations to drive the real auto-load implementation
directly instead of asserting on source text only. No runtime change.
…ip adapters

Review follow-ups, all verified against the backend inventory services:

- Directory-based local GGUFs (LM Studio, models dir, custom folders) are
  flagged requires_variant by the backend, so the fallback now resolves the
  smallest complete quant through the same variants API the picker card
  uses instead of dropping every directory row.
- A folder holding both GGUF and safetensors weights yields two rows with
  the same load target; remembered-model matching now requires the row
  format to agree with the remembered kind.
- Adapter rows are chat-capable but load by resolving their base model,
  which for a Hub-id base would start an implicit remote fetch; adapters
  are excluded from background auto-load.

Contract tests extended to pin all three behaviors.

@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: db7855538a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

matchesRememberedLocalRow(candidateRow, lastLoaded),
);
if (row) {
markSeen(row.load_id, row.id, row.path, row.model_id);

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 Defer marking remembered local rows seen

When the last-used model is a local GGUF directory, this marks its row as seen before the remembered quant is resolved or loaded. If that remembered quant was deleted/partial or the load fails but another complete quant remains in the same LM Studio/models_dir/custom folder, the fallback loop later hits isSeen(...) and skips the row, so Send reports no model instead of choosing the smallest remaining quant. Only mark the row as deduped after a successful load, or don't use this seen set for the remembered-row retry.

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.

Right, that markSeen ran before the remembered quant was resolved or loaded, so a folder with another complete quant could never fall back. The remembered-local retry no longer pre-marks the row; only the exact failed candidate key is excluded, mirroring the managed-cache remembered path. Verified with a simulation where the remembered Q8_0 fails to load and the same folder's Q4_K_M then loads. Fixed in bf46d8b.

Comment on lines +1449 to +1451
if (repo.partial) return false;
if (repo.capabilities?.can_chat === false) return false;
return !isHiddenModelId(repo.repo_id);

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 Exclude cached adapter repos from autoload

When a LoRA adapter repo is present in the Hugging Face cache, the cached-models inventory classifies it as model_format: "adapter" and leaves it chat-capable, but this filter only rejects partial or explicitly non-chat cached rows. The fresh evidence is that the fallback now builds modelRepos directly from /api/hub/cached-models, so the smallest cached adapter can still be selected; /api/inference/load then auto-detects adapter_config.json and resolves the base model, which can trigger the same implicit Hub fetch that local adapter filtering was meant to prevent. Exclude cached model_format === "adapter" rows here 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.

Confirmed: the cached inventory classifies adapter repos with the same capabilities mapping, so they are chat-capable there too. isAutoLoadableCachedRepo now rejects model_format adapter, matching the local-row exclusion, with a simulation asserting a cached adapter never reaches validate or load. Fixed in bf46d8b.

Comment on lines +1527 to +1529
const variants = await listGgufVariants(row.model_id || row.id, undefined, {
preferLocalCache: true,
localPath: row.path,

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 Use the local path for GGUF variant resolution

When an LM Studio/models_dir/custom GGUF folder has a model_id and that repo is also present in the HF cache, this resolves variants using the repo id even though the candidate will be loaded from row.path. The backend's prefer_local_cache flow checks the HF cache before the supplied localPath, unlike the picker which passes the local path as the repo id, so auto-load can choose a cached quant that is not in the local folder and then fail /load with row.path + quant despite other local quants being available. Resolve local rows from row.id/row.path or otherwise force the path scan first.

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.

Confirmed against the variants service: prefer_local_cache checks the HF cache before the supplied local_path, so a row with a Hub model_id could resolve a quant absent from its own folder. Resolution now uses the row's local path as the repo id, which routes the backend straight to the filesystem scan of the folder the candidate loads from. Covered by a simulation where the folder and cache differ. Fixed in bf46d8b.

…apters

Second round of review follow-ups, verified against the backend services:

- A failed remembered local quant now excludes only that exact candidate
  key instead of marking the whole row seen, so another complete quant in
  the same folder can still load (mirrors the managed-cache remembered
  path).
- Quant resolution for a local GGUF folder scans the folder itself via a
  local-path repo id; the cache-first prefer_local_cache flow could return
  a cache quant missing from the folder when the row also has a Hub
  model_id.
- Cached adapter repos are chat-capable in the cached inventory and
  resolve a base model on load, so they are excluded from background
  auto-load like local adapter rows.

Contract tests extended for all three.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

CI triage for the failures seen on the previous run: all five are pre-existing on main and unrelated to this change.

  • Repo tests (CPU): tests/studio/test_ui_font_scale_contract.py fails on text-[9px] and text-[10px] in features/settings/tabs/voice-tab.tsx, which this PR does not touch; the utilities are already present at the merge base and main's Backend CI is red for the same reason.
  • Chat UI Tests (Mac Studio UI CI): identical failure on main, byte for byte, including the Compare bubble warnings and the TargetClosedError at playwright_extra_ui.py line 548 in the Voice model picker step.
  • Core (all three variants): tests/test_mlx_torch_shim_smoke.py::test_module_children_preserves_direct_child_tree fails identically on main's latest Core run; this PR contains no Python library changes.

Everything exercised by this PR passes: Frontend build + bundle sanity, Source lint, pre-commit.ci, both other Chat UI shards, and the studio contract tests (48 passing, including the new autoload guards).

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.

[Bug] No Model Detected Error after Hitting Send

1 participant