Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
164 commits
Select commit Hold shift + click to select a range
b612d3c
Studio: redesign Select model dropdown to match Hub design
Jun 16, 2026
db3f38c
Studio: move section toggle below search, size tabs to label
Jun 16, 2026
9b987b8
Studio: extract pure row-meta helpers into their own module
Jun 16, 2026
df070c3
Studio: content-size the source tabs and add section icons
Jun 16, 2026
fe918ee
Studio: stop source tabs stretching and hide empty Fine-tuned tab
Jun 16, 2026
9113cd7
Studio: keep only fine-tuned models in the Fine-tuned tab
Jun 16, 2026
e5ff580
Studio: show local providers under Downloaded, Recommended first
Jun 16, 2026
4b9dd55
Studio: make Recommended a sortable live Unsloth listing
Jun 16, 2026
6cb21e0
Studio: size Recommended models from the repo name when metadata is m…
Jun 16, 2026
d4639db
Studio: detect model capabilities and family from HF tags
Jun 16, 2026
50ebbc4
Studio: add row details and inline section sorting to Select model
Jun 16, 2026
48236d2
Studio: tune the Select model sort dropdown and trim row badges
Jun 16, 2026
666662b
Studio: extract the PillTabs toggle into a shared module
Jun 16, 2026
96d9b50
Studio: fix Recommended infinite scroll and add a format filter
Jun 16, 2026
c5ba2b1
Studio: default Recommended to Trending, rename Downloaded to On Devi…
Jun 17, 2026
2ef7d98
Studio: size GGUF repos from gguf metadata so large ones flag OOM
Jun 17, 2026
0952e12
Studio: address selector review feedback
Jun 17, 2026
3d3a0f9
Merge main into studio-model-selector-hub-design
Jun 17, 2026
f7f4b6c
Studio: scope Select model search per tab and add an MLX tag
Jun 17, 2026
f90393f
Studio: show local ./models on the On Device tab so they stay selectable
Jun 17, 2026
655f7a7
Studio: add a Hub button beside the Select model search bar
Jun 17, 2026
0a6a02b
Studio: align Select model padding and tighten the format pills
Jun 17, 2026
94ae15d
Studio: label the Hub button Search Hub and match the dropdown width
Jun 17, 2026
44de2f8
Studio: drop the vision and reasoning row badges to declutter
Jun 17, 2026
56b7211
Studio: add a safetensors pill, hide diffusion models, eye on Vision
Jun 17, 2026
cfa1681
Studio: gate recommended folders on real weights and polish the selector
Jun 17, 2026
e12fdcd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 17, 2026
ccffeb9
Studio: align the dark Select model panel with the sidebar
Jun 17, 2026
392118e
Studio: re-derive the Select model tab on open
Jun 17, 2026
c72a0a7
Studio: fold Custom into On Device and polish the picker
Jun 17, 2026
6c1ae6b
Merge remote-tracking branch 'origin/main' into studio-model-selector…
Jun 17, 2026
bb26c0d
Studio: fix On Device controls and nudge the folder browser close
Jun 17, 2026
ac760bf
Studio: run the Select model search on the Hub search stack
Jun 17, 2026
c4689fc
Studio: trim the recommended sort to Recommended, Trending, Recent
Jun 17, 2026
5cc4fe5
Studio: give the section tabs room off the rounded edge
Jun 17, 2026
dc00fc7
Studio: drop the legacy HF search hooks for the Hub ones
Jun 17, 2026
470dccb
Fix model selector section toggle proportions
Jun 17, 2026
c285df2
Tighten model selector width and tab padding
Jun 17, 2026
9def3b3
Refine Recommended formats, sort width and tab padding
Jun 17, 2026
5f29ff7
Flush section toggle and match dropdown font to Search Hub
Jun 17, 2026
dde36e9
Fix sort menu checkmark overlap and lock dropdown widths
Jun 17, 2026
0694bf7
Keep section toggle and dropdowns on one row
Jun 17, 2026
a80f014
Studio: pre-load inference settings dialog with native context
Jun 18, 2026
3727c97
Studio: model selector polish and memory-aware load warning
Jun 18, 2026
cf0bd69
Studio: fix load dialog memory warning budget and KV dropdown width
Jun 18, 2026
aa160c7
Studio: fold fine-tuned models into On Device tab
Jun 18, 2026
941a990
Studio: stage load settings in the sidebar with a Load on selection t…
Jun 18, 2026
51d9ee8
Studio: always show the fine-tuned shortcut and smooth out the picker
Jun 18, 2026
c4cc0be
Merge branch 'main' into studio-model-selector-hub-design
Jun 18, 2026
2d4d27d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 18, 2026
def681c
Studio: add quantization display options and drop the fine-tuned empt…
Jun 18, 2026
4be8691
Studio: let expanded quantizations collapse on click and split the On…
Jun 18, 2026
fa51cc6
Studio: reorder chat settings and rename the model section
Jun 18, 2026
c92dcfe
Studio: tighten the On/Off lines in the model setting descriptions
Jun 18, 2026
fc12993
Studio: top-align the Load on selection toggle
Jun 18, 2026
a10723f
Studio: put the gear hint and example chip on one line
Jun 18, 2026
0844a4c
Studio: move the New badge from API keys to Chat settings
Jun 18, 2026
851daaf
Studio: line the Load on selection toggle up with the first descripti…
Jun 18, 2026
3b1d5b4
Studio: label the chat menu item Chat with Files (RAG)
Jun 18, 2026
bacd3e3
Studio: drop the pill around the gear example so it fits on one line
Jun 18, 2026
9a14b58
Studio: fold the gear example into the description line spacing
Jun 18, 2026
ff63200
Studio: scope Show all quantizations to On Device only
Jun 18, 2026
a8450e3
Studio: tidy On Device GGUF rows
Jun 18, 2026
d9a8b64
Studio: pin the eject button and tidy General settings
Jun 18, 2026
c1eac51
Studio: add left padding before the gear example
Jun 18, 2026
421819a
Studio: make the eject footer a sticky bar over the list
Jun 18, 2026
a5e1500
Studio: drop the eject footer background, keep it a sticky button
Jun 18, 2026
2a41bc2
Studio: give the eject button a solid background
Jun 18, 2026
625c00a
Studio: restore the eject footer block, keep hover on the button only
Jun 18, 2026
30fec6c
Studio: show the vision badge on On Device rows without expanding
Jun 18, 2026
f8ab84b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 18, 2026
7a75aa9
Make LM Studio and Local models sections collapsible
Jun 18, 2026
4ca6382
Fade the eject footer instead of a solid block
Jun 18, 2026
eb9d2f9
Wrap the vision badge in a bordered pill
Jun 18, 2026
c9e171d
Taller model list with the eject footer pinned to the bottom
Jun 18, 2026
dbe06ff
Use purple for the vision badge to set it apart from GGUF
Jun 18, 2026
d66752b
Reduce the model list height
Jun 18, 2026
5c717fc
Make the eject button inline with no background block
Jun 18, 2026
58ee3dd
Match the vision badge color to the Hub indigo tone
Jun 18, 2026
934f7e4
Shorten the model list and square off the format tags
Jun 18, 2026
5ae4ad4
Pin the eject button so it floats at the bottom of the list
Jun 18, 2026
a9152ca
Give the floating eject button a tinted background
Jun 18, 2026
9ada5b7
Add bottom clearance so the list ends on white space under the eject …
Jun 18, 2026
4c09109
Match eject button to the menu background and unify the settings gear…
Jun 18, 2026
ed1bd42
Move eject below the list and match its shadow and dark background
Jun 18, 2026
c350e74
Drop the min height so short model lists leave no white space
Jun 18, 2026
79794a0
Remove the eject button fill so it never covers the list
Jun 18, 2026
8d68063
Nest dropdown hover radius inside the menu corners
Jun 18, 2026
ac09e4f
Float the eject pill again and fix sort dropdown hover radius
Jun 18, 2026
479c23d
Make the eject button opaque in both themes on hover and dark
Jun 18, 2026
0dab2bb
Trim the model menu bottom padding so it stops clipping the last row
Jun 18, 2026
e417eea
Match dark eject background to the Search Hub button and pad row indi…
Jun 18, 2026
b79ba5f
Fade the model list bottom edge while rows sit below the fold
Jun 18, 2026
cae9637
Lift the eject button and trim the section toggle right padding
Jun 18, 2026
a902119
Nudge the model list taller and run the bottom fade to the box edge
Jun 18, 2026
ef90892
Nudge the model list slightly taller
Jun 18, 2026
3f3e7fa
Remove the eject button shadow
Jun 18, 2026
5b012fe
Align the eject button to the right
Jun 18, 2026
ad9656c
Widen the Search Hub and dropdowns and right-align them
Jun 18, 2026
355f37a
Seat the eject button at the base and restore On Device right padding
Jun 18, 2026
77e78d7
Reduce the Search Hub and dropdown width by 4px
Jun 18, 2026
f3ef819
Widen the model menu so the section toggle keeps its padding
Jun 18, 2026
9a41b6a
Make the eject button an icon-only button with shadow
Jun 18, 2026
21caac3
Tighten section tab padding to cut the grey between tabs
Jun 18, 2026
c5ad30a
Revert section tab padding back to px-3
Jun 18, 2026
8935576
Remove the section toggle trailing padding
Jun 18, 2026
e57c283
Add an eject button beside the model selector trigger
Jun 18, 2026
d61af76
Shrink the in-list eject button to a smaller proportional size
Jun 18, 2026
51f8b3e
Raise the in-list eject button
Jun 18, 2026
1332c64
Make the trigger eject a bare icon next to the dropdown arrow
Jun 18, 2026
719846a
Revert eject back to the labeled button on the right
Jun 18, 2026
9498e10
Place the format and sort dropdowns next to the section toggle
Jun 18, 2026
418b134
Raise the eject button and shorten its label to Eject model
Jun 18, 2026
3c8abd9
Widen the gap between the toggle and dropdowns slightly
Jun 18, 2026
9d7bb09
Align Search Hub with the last dropdown via a shared-width grid
Jun 18, 2026
3f3f432
Narrow the model menu for symmetric padding
Jun 18, 2026
17fe74c
Stretch the search row so Search Hub lines up with the last dropdown
Jun 18, 2026
da8a166
Inset the list so the right padding matches the left
Jun 18, 2026
22f6959
Right-align dropdowns and full-width search so Search Hub meets the l…
Jun 18, 2026
9c4766a
Pack section toggle and dropdowns with a uniform gap
Jun 18, 2026
fe085f9
Inset search row so Search Hub aligns with the Trending dropdown
Jun 18, 2026
54c5088
Trim model menu right padding to match the left
Jun 18, 2026
607c51c
Nudge model list scrollbar inward
Jun 18, 2026
edea463
Move eject button to the bottom left with a light shadow
Jun 18, 2026
4191d92
Shorten show all quantizations description
Jun 18, 2026
db0475f
Keep eject button right-aligned, nudged in from the edge
Jun 18, 2026
266a3c9
Merge branch 'main' into studio-model-selector-hub-design
Imagineer99 Jun 18, 2026
164e77f
Move Connected into the section toggle as a cloud-icon tab
Jun 18, 2026
55a4010
Align eject button with the format tag edge
Jun 18, 2026
947fe31
Right-align Connected layout so Search Hub meets Trending
Jun 18, 2026
7bd1983
Download selected models through the Hub download manager
Jun 18, 2026
05e4859
Add Other models section for non-Unsloth downloads
Jun 18, 2026
42ebe6c
Add directions icon and shortcut for Other models section
Jun 18, 2026
6d089a9
Space out subheadings and gate Other models on non-Unsloth downloads
Jun 18, 2026
38c7891
Use direction-right icon for Other models
Jun 18, 2026
dc4e968
Use flag icon for Other models
Jun 18, 2026
3ddcfb1
Widen Connected menu so dropdowns align with Search Hub
Jun 18, 2026
f35aed9
Model selector: truncate long quant labels and tidy layout
Jun 18, 2026
b59092e
Merge remote-tracking branch 'origin/main' into studio-model-selector…
Jun 18, 2026
cafe7fb
Model selector: drop stale custom section on restore
Jun 18, 2026
2990141
Model selector: align the non-connected search bar with the All dropdown
Jun 18, 2026
9f583cc
Studio chat model selector: remember last tab, route non-GGUF downloa…
Jun 18, 2026
0344869
Studio downloads panel: widen left padding on header and rows
Jun 18, 2026
834f096
Studio: update cached-gguf route tests for the has_vision field
Jun 18, 2026
a2a1c20
Merge remote-tracking branch 'origin/main' into studio-model-selector…
Jun 18, 2026
d12d7f5
Studio: keep MLX/safetensors selectable in chat-only Mac search
Jun 18, 2026
95ff5aa
Model selector: restore global model search and fix GGUF/device-fit r…
shimmyshimmer Jun 18, 2026
37065b4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 18, 2026
e572aa1
Model selector: classify suffixless local GGUF folders consistently
shimmyshimmer Jun 18, 2026
ab8a896
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 18, 2026
a5ecc02
Merge remote-tracking branch 'origin/main' into studio-model-selector…
shimmyshimmer Jun 18, 2026
a3313e5
Merge remote-tracking branch 'origin/main' into studio-model-selector…
shimmyshimmer Jun 18, 2026
0cc0da2
Studio model selector: tighten section spacing
Jun 19, 2026
bd449a8
Hub: format filter fix, sort defaults, avatar and layout polish
Jun 19, 2026
6b28925
Hub: hide the RAG embedding model from browse previews
Jun 19, 2026
cc4d522
Merge branch 'main' into studio-model-selector-hub-design
danielhanchen Jun 20, 2026
97da1aa
Studio: skip hidden dirs when checking a folder for downloaded models
shimmyshimmer Jun 20, 2026
1e85fec
Fix/adjust model selector handling for PR #6364
wasimysaid Jun 20, 2026
35c8ed4
Merge remote-tracking branch 'origin/main' into studio-model-selector…
shimmyshimmer Jun 22, 2026
7b71d64
Studio: address codex review on the staging/recommended-folder paths
shimmyshimmer Jun 22, 2026
b1abe33
Studio: name-gate .bin weight detection and complete selector prefere…
shimmyshimmer Jun 22, 2026
73a3f62
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 22, 2026
5ad8b38
Studio: validate Ollama blobs, gate staged context, honor RAM budget …
shimmyshimmer Jun 22, 2026
a4e57f0
Studio: scope Hub search to Recommended, fix staged non-GGUF settings…
shimmyshimmer Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions studio/backend/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ class GgufVariantsResponse(BaseModel):
default_variant: Optional[str] = Field(
None, description = "Recommended default quantization variant"
)
context_length: Optional[int] = Field(
None,
description = "Native max context from GGUF metadata; set once a variant is downloaded",
)


class LocalModelInfo(BaseModel):
Expand All @@ -170,6 +174,11 @@ class LocalModelInfo(BaseModel):
None,
description = "HF repo id for cached models, e.g. org/model",
)
model_format: Optional[str] = Field(
None,
description = "Detected weights format ('gguf' when known). Lets the UI "
"classify scanned folders whose name lacks a -GGUF suffix.",
)
updated_at: Optional[float] = Field(
None,
description = "Unix timestamp of latest observed update",
Expand Down
325 changes: 314 additions & 11 deletions studio/backend/routes/models.py

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions studio/backend/tests/test_cached_gguf_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monk
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
}
]

Expand All @@ -103,6 +104,7 @@ def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_
"repo_id": "Org/Model-Without-Suffix",
"size_bytes": 7_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
}
]

Expand Down Expand Up @@ -197,6 +199,7 @@ def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(monkeypatch,
"repo_id": "org/dupe",
"size_bytes": 6_000,
"cache_path": str(larger.repo_path),
"has_vision": False,
}
]

Expand Down Expand Up @@ -226,6 +229,7 @@ def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp
"repo_id": "Org/SharedBlobRepo",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
}
]

Expand Down Expand Up @@ -275,6 +279,7 @@ def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypa
"repo_id": "Org/MixedRepo",
"size_bytes": 5_000,
"cache_path": str(mixed.repo_path),
"has_vision": False,
}
]

Expand All @@ -301,6 +306,7 @@ def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
"repo_id": "Org/PartialDownload",
"size_bytes": 5_000,
"cache_path": str(partial.repo_path),
"has_vision": False,
}
]

Expand Down Expand Up @@ -336,6 +342,7 @@ def revisions(self):
"repo_id": "Org/Healthy",
"size_bytes": 5_000,
"cache_path": str(healthy.repo_path),
"has_vision": False,
}
]

Expand Down Expand Up @@ -411,6 +418,7 @@ def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeyp
"repo_id": "Org/VisionGguf",
"size_bytes": 5_000,
"cache_path": str(vision_repo.repo_path),
"has_vision": True,
}
]

Expand Down Expand Up @@ -464,6 +472,7 @@ def resolve(self):
"repo_id": "Org/Active",
"size_bytes": 5_000,
"cache_path": str(tmp_path / "active"),
"has_vision": False,
}
]

Expand Down
117 changes: 117 additions & 0 deletions studio/backend/tests/test_local_model_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Tests for local GGUF ``model_format`` classification (PR #6364 follow-up).

Suffixless GGUF folders (custom folders / LM Studio) carry no ``-GGUF`` name
hint, so the scanners must surface ``model_format = "gguf"`` for the UI to route
them through the GGUF load path. The rule, shared by ``_dir_model_format`` and
``_scan_models_dir``: a directory is GGUF-format when it holds ``.gguf`` files
and no non-GGUF weights (``.safetensors`` / ``.bin``); a stray ``config.json``
must not disqualify it.

No GPU/network: only file names and sizes are inspected.
"""

from __future__ import annotations

import sys
import types
from pathlib import Path

# Keep runnable without optional logging deps (mirrors the sibling tests).
if "structlog" not in sys.modules:

class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None

sys.modules["structlog"] = types.SimpleNamespace(
BoundLogger = _DummyLogger,
get_logger = lambda *args, **kwargs: _DummyLogger(),
)

import routes.models as models_route


def _touch(path: Path) -> Path:
path.parent.mkdir(parents = True, exist_ok = True)
path.write_bytes(b"\0")
return path


def test_dir_model_format_gguf_only(tmp_path):
d = tmp_path / "model"
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) == "gguf"


def test_dir_model_format_gguf_with_config_is_still_gguf(tmp_path):
# A config.json alongside the .gguf must not flip it to non-GGUF.
d = tmp_path / "model"
_touch(d / "config.json")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) == "gguf"


def test_dir_model_format_mixed_weights_is_not_gguf(tmp_path):
# Real safetensors weights present -> not a GGUF folder.
d = tmp_path / "model"
_touch(d / "model.safetensors")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) is None


def test_dir_model_format_no_gguf(tmp_path):
d = tmp_path / "model"
_touch(d / "config.json")
_touch(d / "model.safetensors")
assert models_route._dir_model_format(d) is None


def test_dir_model_format_ignores_tokenizer_bin(tmp_path):
# A companion tokenizer.bin is not a weight file, so a GGUF folder shipping
# one is still GGUF (not misread as a plain .bin checkpoint).
d = tmp_path / "model"
_touch(d / "tokenizer.bin")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) == "gguf"


def test_dir_model_format_weight_bin_is_not_gguf(tmp_path):
# A real PyTorch weight .bin alongside a .gguf means mixed weights -> None.
d = tmp_path / "model"
_touch(d / "pytorch_model.bin")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) is None


def test_scan_models_dir_classifies_gguf_with_config(tmp_path):
root = tmp_path / "models"
# GGUF repo that also ships a config.json (the regression case).
_touch(root / "gguf_repo" / "config.json")
_touch(root / "gguf_repo" / "model-Q4_K_M.gguf")
# A plain safetensors checkpoint stays non-GGUF.
_touch(root / "st_repo" / "config.json")
_touch(root / "st_repo" / "model.safetensors")
# A standalone .gguf file is GGUF.
_touch(root / "loose.gguf")

fmt = {Path(m.path).name: m.model_format for m in models_route._scan_models_dir(root)}

assert fmt["gguf_repo"] == "gguf"
assert fmt["st_repo"] is None
assert fmt["loose.gguf"] == "gguf"


def test_scan_models_dir_classifies_root_gguf_with_config(tmp_path):
# Custom scan folders can point directly at a GGUF repo, not only at a
# parent directory that contains model repos.
root = tmp_path / "SuffixlessRepo"
_touch(root / "config.json")
_touch(root / "model-Q4_K_M.gguf")

[row] = models_route._scan_models_dir(root)

assert row.path == str(root)
assert row.model_format == "gguf"
166 changes: 166 additions & 0 deletions studio/backend/tests/test_recommended_folders_has_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""
Regression test for /recommended-folders suggesting empty scaffolds.

The endpoint used to surface any well-known dir that merely existed, so a
freshly installed LM Studio or Ollama (empty ``models`` dir) showed up as a
"Recommended" chip with no models behind it. ``_dir_has_downloaded_model``
now gates each candidate on real weights: a GGUF/safetensors file anywhere in
the tree, or a non-empty Ollama ``manifests/`` beside ``blobs/``.

``routes.models`` pulls the full backend dep tree, so we extract the real
helper (and its ``_safe_is_dir`` dependency) from the source via AST and run
the shipped code in isolation, mirroring
``test_recommended_folders_permission.py``.

Run:
python -m pytest studio/backend/tests/test_recommended_folders_has_model.py -v
"""

import ast
import json
import os
from pathlib import Path

_backend_root = Path(__file__).resolve().parent.parent
_models_src = _backend_root / "routes" / "models.py"


def _load_has_downloaded_model():
"""Return the real ``_dir_has_downloaded_model`` (plus its ``_safe_is_dir``
and ``_is_weight_bin`` deps, and the ``_WEIGHT_BIN_PREFIXES`` constant the
latter reads) without importing the heavy module."""
tree = ast.parse(_models_src.read_text())
wanted = {"_safe_is_dir", "_dir_has_downloaded_model", "_is_weight_bin"}
body = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in wanted:
body.append(node)
elif isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == "_WEIGHT_BIN_PREFIXES" for t in node.targets
):
body.append(node)
got = {n.name for n in body if isinstance(n, ast.FunctionDef)}
assert got == wanted, f"helpers missing from source: {wanted - got}"
module = ast.Module(body = body, type_ignores = [])
ns: dict = {"Path": Path, "os": os, "json": json}
exec(compile(module, f"<extracted {_models_src}>", "exec"), ns)
return ns["_dir_has_downloaded_model"]


has_downloaded_model = _load_has_downloaded_model()


def test_empty_scaffold_is_false(tmp_path):
empty = tmp_path / "lmstudio" / "models"
empty.mkdir(parents = True)
assert has_downloaded_model(empty) is False


def test_lmstudio_gguf_is_true(tmp_path):
# models/publisher/repo/file.gguf (LM Studio's nested layout).
repo = tmp_path / "models" / "bartowski" / "Qwen3-4B-GGUF"
repo.mkdir(parents = True)
(repo / "q4.gguf").write_bytes(b"x")
assert has_downloaded_model(tmp_path / "models") is True


def test_safetensors_is_true(tmp_path):
repo = tmp_path / "models" / "repo"
repo.mkdir(parents = True)
(repo / "model.safetensors").write_bytes(b"x")
assert has_downloaded_model(tmp_path / "models") is True


def test_ollama_empty_scaffold_is_false(tmp_path):
models = tmp_path / "ollama" / "models"
(models / "manifests").mkdir(parents = True)
(models / "blobs").mkdir()
assert has_downloaded_model(models) is False


def test_ollama_with_manifest_is_true(tmp_path):
models = tmp_path / "ollama" / "models"
manifest = models / "manifests" / "registry.ollama.ai" / "library" / "llama3"
manifest.mkdir(parents = True)
# A real manifest references its weights via an image.model layer; the
# referenced blob must exist on disk for the model to be loadable.
(manifest / "latest").write_text(
json.dumps(
{
"layers": [
{
"mediaType": "application/vnd.ollama.image.model",
"digest": "sha256:abc",
}
]
}
)
)
(models / "blobs").mkdir()
(models / "blobs" / "sha256-abc").write_bytes(b"x")
assert has_downloaded_model(models) is True


def test_ollama_manifest_without_blob_is_false(tmp_path):
# A failed/pruned pull leaves the manifest behind but its model blob is
# gone: the chip must not lead to an empty picker.
models = tmp_path / "ollama" / "models"
manifest = models / "manifests" / "registry.ollama.ai" / "library" / "llama3"
manifest.mkdir(parents = True)
(manifest / "latest").write_text(
json.dumps(
{
"layers": [
{
"mediaType": "application/vnd.ollama.image.model",
"digest": "sha256:missing",
}
]
}
)
)
(models / "blobs").mkdir() # empty: the referenced blob never landed
assert has_downloaded_model(models) is False


def test_non_model_files_is_false(tmp_path):
junk = tmp_path / "junk"
junk.mkdir()
(junk / "readme.txt").write_text("hi")
assert has_downloaded_model(junk) is False


def test_pytorch_bin_weights_are_true(tmp_path):
# A folder whose only weights are PyTorch .bin checkpoints (which the local
# scanner accepts) should still earn a Recommended chip.
repo = tmp_path / "models" / "repo"
repo.mkdir(parents = True)
(repo / "config.json").write_text("{}")
(repo / "pytorch_model.bin").write_bytes(b"x")
assert has_downloaded_model(tmp_path / "models") is True


def test_non_weight_bin_is_false(tmp_path):
# A stray .bin that is not a weight file (e.g. tokenizer.bin) must not count.
repo = tmp_path / "models" / "repo"
repo.mkdir(parents = True)
(repo / "tokenizer.bin").write_bytes(b"x")
assert has_downloaded_model(tmp_path / "models") is False


def test_hidden_subtree_does_not_starve_the_budget(tmp_path):
# A real model dir that also holds a huge hidden subtree (e.g. a .git or
# .cache). The hidden entries must not exhaust max_entries before the walk
# reaches the actual weights, which would falsely report "no model".
models = tmp_path / "models"
git = models / ".git" / "objects"
git.mkdir(parents = True)
for i in range(50):
(git / f"obj{i}").write_bytes(b"x")
repo = models / "repo"
repo.mkdir()
(repo / "model.safetensors").write_bytes(b"x")
assert has_downloaded_model(models, max_entries = 10) is True
Loading
Loading