Studio: hide the RAG embedder and llama.cpp probe from the hub cached inventory (#7018)
* Studio: hide infra models from the hub cached inventory The hub inventory scans behind /api/hub/cached-gguf and /api/hub/cached-models returned the llama.cpp install validation probe (ggml-org/models) and the RAG embedder (unsloth/bge-small-en-v1.5[-GGUF]) as on-device models. Share the hidden-model check from routes/models.py via utils/models/hidden_models.py and apply it in both scans. A GGUF infra repo stays visible when the user explicitly downloaded a variant through the Hub, since variant manifests only exist for user-initiated downloads. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make On Device trust the hub inventory, match repo ids exactly, lighten the hidden-model import Follow-up on the hub cached-inventory hidden-model change, addressing the review. On Device now trusts the Hub inventory API for cached rows. The backend already hides the RAG embedder and the llama.cpp probe and re-includes a GGUF infra repo once the user downloads a variant through the Hub, but the frontend was re-hiding it by repo id, so the user-downloaded variant never appeared in the On Device list or the count. isVisibleInventoryRow now short-circuits cached rows (kind === "cache") to visible and keeps client-side needle hiding only for local filesystem rows and Discover. is_hidden_model matches Hub repo ids exactly (case-insensitive) against the probe plus the effective embedder and its GGUF companion, instead of substring matching the configured-embedder basename. A custom embedder with a generic basename like org/model no longer hides unrelated cached repos such as user/model-chat or org/model-instruct. The probe filename and local-path embedders keep exact matching. The helper moves to utils/hidden_models.py and is imported at module scope in the hub cache scanner, so it no longer pulls in utils/models/__init__ (the eager model-config/checkpoint stack) and a broken import fails at startup instead of being swallowed per-repo and silently emptying the inventory. routes.models keeps the _is_hidden_model and _safe_resolve aliases and drops the unused _HF_REPO_ID_RE re-export that was failing source lint. Tests: exact repo-id matching with a custom embedder, the cached-models scan keeping an unrelated repo, and a clean-interpreter check that the helper imports without the model-config stack. * Studio: match the llama.cpp probe filename on both path separators The hidden-model check compared the probe's on-disk filename with Path(value).name, which on a POSIX interpreter does not split a Windows-style path ("...\stories260K.gguf") and would let the probe through. Split on both separators so the probe is matched regardless of which OS produced the path, matching the tolerance of the previous substring check. Adds a Windows-path assertion to the probe test. * Studio: harden hidden infra model handling * Fix hidden cache row confirmation * Fix hidden local rows and confirmed hint merges * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle snapshot-configured hidden models * Hide basename-only default embedders * Fix dynamic embedder inventory filtering * Studio: hide the configured RAG embedder from Discover and feed rows --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
This commit is contained in:
parent
6d8c18cd1a
commit
74d1a284eb
22 changed files with 899 additions and 100 deletions
|
|
@ -201,7 +201,11 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
continue
|
||||
# Skip what Unsloth hides from its pickers (validation probe, RAG embed
|
||||
# weights): not chat models, so never an auto-switch target.
|
||||
if _is_hidden_model(raw_id, getattr(info, "path", None)):
|
||||
if _is_hidden_model(
|
||||
raw_id,
|
||||
getattr(info, "model_id", None),
|
||||
getattr(info, "path", None),
|
||||
):
|
||||
continue
|
||||
# Advertise a client-facing alias, not an absolute filesystem path.
|
||||
loader_id = _advertised_loader_id(info)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,22 @@ def _names_gguf(model: str) -> bool:
|
|||
return "gguf" in re.split(r"[^a-z0-9]+", model.lower())
|
||||
|
||||
|
||||
def gguf_repo_for_embedding_model(model: str) -> str:
|
||||
"""GGUF repo for ``model``, honoring an explicit companion override."""
|
||||
if "RAG_EMBED_GGUF_REPO" in os.environ:
|
||||
return EMBED_GGUF_REPO
|
||||
if model == DEFAULT_EMBEDDING_MODEL:
|
||||
return EMBED_GGUF_REPO
|
||||
if _names_gguf(model):
|
||||
return model
|
||||
return f"{model}-GGUF"
|
||||
|
||||
|
||||
def default_gguf_repo() -> str:
|
||||
"""GGUF companion for the env/default embedding model."""
|
||||
return gguf_repo_for_embedding_model(EMBEDDING_MODEL)
|
||||
|
||||
|
||||
def effective_gguf_repo() -> str:
|
||||
"""GGUF repo for the llama-server backend, tracking the effective model.
|
||||
|
||||
|
|
@ -95,14 +111,7 @@ def effective_gguf_repo() -> str:
|
|||
``-GGUF`` companion repo (the unsloth convention the default pair follows),
|
||||
or is used as-is when it already names a GGUF repo.
|
||||
"""
|
||||
if "RAG_EMBED_GGUF_REPO" in os.environ:
|
||||
return EMBED_GGUF_REPO
|
||||
model = effective_embedding_model()
|
||||
if model == DEFAULT_EMBEDDING_MODEL:
|
||||
return EMBED_GGUF_REPO
|
||||
if _names_gguf(model):
|
||||
return model
|
||||
return f"{model}-GGUF"
|
||||
return gguf_repo_for_embedding_model(effective_embedding_model())
|
||||
|
||||
|
||||
# llama-server backend only. F16 over Q8_0: faster (no per-block dequant for this
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ from hub.services.models.common import (
|
|||
_runtime_for_format,
|
||||
)
|
||||
|
||||
# Imported at module scope (not inside the per-repo scan loop) so a broken
|
||||
# import surfaces at startup instead of silently emptying the inventory: the
|
||||
# scan loop swallows per-repo exceptions and would drop every repo. Lives under
|
||||
# ``utils`` (not ``utils.models``) to avoid the eager model-config/checkpoint
|
||||
# imports in ``utils/models/__init__.py``.
|
||||
from utils.hidden_models import is_hidden_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_repo_size_cache: "OrderedDict[tuple[str, str, str], tuple[int, frozenset[str], float]]" = (
|
||||
|
|
@ -243,6 +250,13 @@ def invalidate_hf_cache_scans() -> None:
|
|||
hf_cache_scan.invalidate_hf_cache_scans()
|
||||
|
||||
|
||||
def _is_hidden_infra_repo(*values: str | None) -> bool:
|
||||
"""True for infra-only repos (the RAG embedder and the llama.cpp install
|
||||
validation probe) that are cached as a side effect of Studio itself and are
|
||||
not usable chat models."""
|
||||
return is_hidden_model(*values)
|
||||
|
||||
|
||||
def _scan_cached_gguf() -> list[dict]:
|
||||
"""Synchronous HF-cache disk walk for GGUF repos; runs in a worker thread."""
|
||||
cache_scans = all_hf_cache_scans()
|
||||
|
|
@ -254,13 +268,24 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
if str(repo_info.repo_type) != "model":
|
||||
continue
|
||||
repo_id = repo_info.repo_id
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_path = _cached_model_snapshot_path(repo_path)
|
||||
total_size = _repo_gguf_size_bytes(repo_info)
|
||||
has_variant_state, variant_state_size = _gguf_variant_state_summary(repo_id)
|
||||
is_hidden_infra = _is_hidden_infra_repo(
|
||||
repo_id,
|
||||
str(repo_path),
|
||||
str(snapshot_path) if snapshot_path is not None else None,
|
||||
)
|
||||
# Hide infra repos unless the user downloaded a variant via
|
||||
# the Hub; variant state only exists for user downloads.
|
||||
if is_hidden_infra and not has_variant_state:
|
||||
continue
|
||||
if total_size == 0 and not has_variant_state:
|
||||
continue
|
||||
partial = hf_cache_scan.is_gguf_repo_partial(
|
||||
repo_id,
|
||||
Path(repo_info.repo_path),
|
||||
repo_path,
|
||||
)
|
||||
if total_size == 0 and not partial:
|
||||
continue
|
||||
|
|
@ -283,6 +308,9 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
requires_variant = True,
|
||||
)
|
||||
)
|
||||
# Visible infra variants remain management-only.
|
||||
if is_hidden_infra:
|
||||
row["capabilities"]["can_chat"] = False
|
||||
if _prefer_cache_row(row, existing):
|
||||
seen_lower[key] = row
|
||||
except Exception as e:
|
||||
|
|
@ -475,6 +503,15 @@ def _scan_cached_models() -> list[dict]:
|
|||
if str(repo_info.repo_type) != "model":
|
||||
continue
|
||||
repo_id = repo_info.repo_id
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_path = _cached_model_snapshot_path(repo_path)
|
||||
# The non-GGUF embedder has no variant downloads; always hide.
|
||||
if _is_hidden_infra_repo(
|
||||
repo_id,
|
||||
str(repo_path),
|
||||
str(snapshot_path) if snapshot_path is not None else None,
|
||||
):
|
||||
continue
|
||||
has_main_gguf = _repo_has_gguf_files(repo_info)
|
||||
payload = _repo_non_gguf_model_payload(repo_info)
|
||||
if payload.size_bytes == 0:
|
||||
|
|
@ -486,7 +523,6 @@ def _scan_cached_models() -> list[dict]:
|
|||
continue
|
||||
key = repo_id.lower()
|
||||
existing = seen_lower.get(key)
|
||||
repo_path = Path(repo_info.repo_path)
|
||||
snapshot_partial = hf_cache_scan.is_snapshot_partial(
|
||||
"model",
|
||||
repo_id,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from hub.utils.paths import (
|
|||
)
|
||||
from hub.services.models import common as model_common
|
||||
from hub.services.models.ollama import scan_ollama_dir
|
||||
from utils.hidden_models import is_hidden_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_MAX_MODELS_PER_CUSTOM_FOLDER = 200
|
||||
|
|
@ -623,6 +624,20 @@ def _dedupe_local_models(local_models: List[LocalModelInfo]) -> list[LocalModelI
|
|||
)
|
||||
|
||||
|
||||
def _filter_hidden_models(local_models: List[LocalModelInfo]) -> list[LocalModelInfo]:
|
||||
"""Remove infrastructure-only models from the shared local inventory."""
|
||||
visible: list[LocalModelInfo] = []
|
||||
for model in local_models:
|
||||
resolved_cache_path = (
|
||||
hf_cache_scan.resolve_hf_cache_realpath(Path(model.path))
|
||||
if model.source == "hf_cache"
|
||||
else None
|
||||
)
|
||||
if not is_hidden_model(model.id, model.model_id, model.path, resolved_cache_path):
|
||||
visible.append(model)
|
||||
return visible
|
||||
|
||||
|
||||
async def list_local_models_response(models_dir: str = "./models") -> LocalModelListResponse:
|
||||
"""List local model candidates from every supported on-device source."""
|
||||
hf_cache_dir = _resolve_hf_cache_dir()
|
||||
|
|
@ -653,7 +668,7 @@ async def list_local_models_response(models_dir: str = "./models") -> LocalModel
|
|||
ollama_dirs,
|
||||
)
|
||||
local_models += await _collect_models_from_custom_folders()
|
||||
models = _dedupe_local_models(local_models)
|
||||
models = _dedupe_local_models(_filter_hidden_models(local_models))
|
||||
|
||||
return LocalModelListResponse(
|
||||
models_dir = str(models_root),
|
||||
|
|
|
|||
|
|
@ -439,6 +439,287 @@ def test_cached_gguf_scan_includes_variant_state_without_completed_gguf(monkeypa
|
|||
assert row["capabilities"]["requires_variant"] is True
|
||||
|
||||
|
||||
def test_cached_gguf_scan_hides_infra_repos_without_user_downloads(monkeypatch, tmp_path):
|
||||
probe = _repo(
|
||||
"ggml-org/models",
|
||||
[_file("tinyllamas/stories260K.gguf", 1_200_000)],
|
||||
tmp_path / "probe",
|
||||
)
|
||||
embedder = _repo(
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
[_file("bge-small-en-v1.5-f16.gguf", 60_000_000)],
|
||||
tmp_path / "embedder",
|
||||
)
|
||||
chat = _repo("Org/Chat-GGUF", [_file("Q4_K_M.gguf", 100)], tmp_path / "chat")
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [probe, embedder, chat])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_gguf()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["Org/Chat-GGUF"]
|
||||
|
||||
|
||||
def test_cached_gguf_scan_keeps_infra_repo_with_user_downloaded_variant(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
embedder = _repo(
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
[
|
||||
_file("bge-small-en-v1.5-f16.gguf", 60_000_000),
|
||||
_file("bge-small-en-v1.5-Q8_0.gguf", 35_000_000),
|
||||
],
|
||||
tmp_path / "embedder",
|
||||
)
|
||||
# Variant manifests only exist for user Hub downloads, not auto-downloads.
|
||||
assert download_manifest.write_manifest(
|
||||
"model",
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
"Q8_0",
|
||||
[download_manifest.ExpectedFile(path = "bge-small-en-v1.5-Q8_0.gguf", size = 35_000_000)],
|
||||
"http",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [embedder])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_gguf()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["unsloth/bge-small-en-v1.5-GGUF"]
|
||||
assert result["cached"][0]["capabilities"]["can_chat"] is False
|
||||
|
||||
|
||||
def test_cached_models_scan_hides_non_gguf_embedder(monkeypatch, tmp_path):
|
||||
embedder_path = tmp_path / "hub" / "models--unsloth--bge-small-en-v1.5"
|
||||
embedder_path.mkdir(parents = True)
|
||||
embedder = _repo(
|
||||
"unsloth/bge-small-en-v1.5",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
embedder_path,
|
||||
)
|
||||
chat_path = tmp_path / "hub" / "models--Org--Chat"
|
||||
chat_path.mkdir(parents = True)
|
||||
chat = _repo(
|
||||
"Org/Chat",
|
||||
[_file("config.json", 12), _file("model.safetensors", 100)],
|
||||
chat_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [embedder, chat])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_models()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["Org/Chat"]
|
||||
|
||||
|
||||
def test_cached_scans_hide_embedders_configured_by_cache_path(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
gguf_path = tmp_path / "hub" / "models--Org--PathEmbedder-GGUF"
|
||||
gguf_path.mkdir(parents = True)
|
||||
gguf = _repo(
|
||||
"Org/PathEmbedder-GGUF",
|
||||
[_file("model-F16.gguf", 60_000_000)],
|
||||
gguf_path,
|
||||
)
|
||||
model_path = tmp_path / "hub" / "models--Org--PathEmbedder"
|
||||
model_path.mkdir(parents = True)
|
||||
model = _repo(
|
||||
"Org/PathEmbedder",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
model_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_embedding_model",
|
||||
lambda: str(model_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_gguf_repo",
|
||||
lambda: str(gguf_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [gguf, model])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
assert cache_inventory._scan_cached_gguf() == []
|
||||
assert cache_inventory._scan_cached_models() == []
|
||||
|
||||
|
||||
def test_cached_scans_hide_embedders_configured_by_snapshot_path(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
gguf_path = tmp_path / "hub" / "models--Org--SnapshotEmbedder-GGUF"
|
||||
gguf_snapshot = gguf_path / "snapshots" / "gguf-revision"
|
||||
gguf_snapshot.mkdir(parents = True)
|
||||
gguf = _repo(
|
||||
"Org/SnapshotEmbedder-GGUF",
|
||||
[_file("model-F16.gguf", 60_000_000)],
|
||||
gguf_path,
|
||||
)
|
||||
model_path = tmp_path / "hub" / "models--Org--SnapshotEmbedder"
|
||||
model_snapshot = model_path / "snapshots" / "model-revision"
|
||||
model_snapshot.mkdir(parents = True)
|
||||
model = _repo(
|
||||
"Org/SnapshotEmbedder",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
model_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_embedding_model",
|
||||
lambda: str(model_snapshot),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_gguf_repo",
|
||||
lambda: str(gguf_snapshot),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [gguf, model])],
|
||||
)
|
||||
|
||||
def _resolve_snapshot(repo_path):
|
||||
return str(
|
||||
{
|
||||
gguf_path: gguf_snapshot,
|
||||
model_path: model_snapshot,
|
||||
}.get(Path(repo_path), Path(repo_path))
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"resolve_hf_cache_realpath",
|
||||
_resolve_snapshot,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
assert cache_inventory._scan_cached_gguf() == []
|
||||
assert cache_inventory._scan_cached_models() == []
|
||||
|
||||
|
||||
def test_cached_models_scan_keeps_unrelated_repo_with_custom_generic_embedder(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
# A custom embedder with a generic basename ("org/model") must be hidden by
|
||||
# EXACT repo-id match only. An unrelated cached chat model whose id merely
|
||||
# contains "model" (e.g. "user/model-chat") must stay on device: substring
|
||||
# basename matching used to drop real chat models from the inventory.
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
|
||||
|
||||
def _model_repo(repo_id: str):
|
||||
path = tmp_path / "hub" / f"models--{repo_id.replace('/', '--')}"
|
||||
path.mkdir(parents = True)
|
||||
return _repo(
|
||||
repo_id,
|
||||
[_file("config.json", 12), _file("model.safetensors", 100)],
|
||||
path,
|
||||
)
|
||||
|
||||
embedder = _model_repo("org/model")
|
||||
chat = _model_repo("user/model-chat")
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [embedder, chat])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
result = {"cached": cache_inventory._scan_cached_models()}
|
||||
|
||||
assert [row["repo_id"] for row in result["cached"]] == ["user/model-chat"]
|
||||
|
||||
|
||||
def test_cached_scans_hide_stale_default_embedder_after_custom_setting(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/custom")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/custom-GGUF")
|
||||
|
||||
gguf = _repo(
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
[_file("bge-small-en-v1.5-f16.gguf", 60_000_000)],
|
||||
tmp_path / "default-gguf",
|
||||
)
|
||||
weights_path = tmp_path / "hub" / "models--unsloth--bge-small-en-v1.5"
|
||||
weights_path.mkdir(parents = True)
|
||||
weights = _repo(
|
||||
"unsloth/bge-small-en-v1.5",
|
||||
[_file("config.json", 12), _file("model.safetensors", 130_000_000)],
|
||||
weights_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [gguf, weights])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda _repo_id, _path: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_inventory.hf_cache_scan,
|
||||
"is_snapshot_partial",
|
||||
lambda _kind, _repo_id, _path: False,
|
||||
)
|
||||
|
||||
assert cache_inventory._scan_cached_gguf() == []
|
||||
assert cache_inventory._scan_cached_models() == []
|
||||
|
||||
|
||||
def test_gguf_variant_requirements_include_split_files_and_preferred_mmproj():
|
||||
requirements = gguf_variants._build_gguf_variant_requirements(
|
||||
[
|
||||
|
|
@ -1610,6 +1891,63 @@ def test_hf_cache_scan_uses_gguf_partial_row_for_variant_state(monkeypatch, tmp_
|
|||
assert rows[0].capabilities.requires_variant is True
|
||||
|
||||
|
||||
def test_local_inventory_filters_custom_embedder_hf_cache_row(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/embedder")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/embedder-GGUF")
|
||||
|
||||
def _row(repo_id: str):
|
||||
repo_path = tmp_path / f"models--{repo_id.replace('/', '--')}"
|
||||
return model_common._local_model_info(
|
||||
scan_path = repo_path,
|
||||
load_path = repo_path,
|
||||
source = "hf_cache",
|
||||
model_format = "safetensors",
|
||||
model_id = repo_id,
|
||||
)
|
||||
|
||||
rows = local_inventory._filter_hidden_models([_row("org/embedder"), _row("org/chat-model")])
|
||||
|
||||
assert [row.model_id for row in rows] == ["org/chat-model"]
|
||||
|
||||
|
||||
def test_local_inventory_filters_embedder_configured_by_snapshot_path(monkeypatch, tmp_path):
|
||||
from core.rag import config as rag_config
|
||||
|
||||
embedder_path = tmp_path / "hub" / "models--org--embedder"
|
||||
embedder_snapshot = embedder_path / "snapshots" / "revision"
|
||||
embedder_snapshot.mkdir(parents = True)
|
||||
chat_path = tmp_path / "hub" / "models--org--chat-model"
|
||||
chat_path.mkdir(parents = True)
|
||||
monkeypatch.setattr(
|
||||
rag_config,
|
||||
"effective_embedding_model",
|
||||
lambda: str(embedder_snapshot),
|
||||
)
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/embedder-GGUF")
|
||||
monkeypatch.setattr(
|
||||
local_inventory.hf_cache_scan,
|
||||
"resolve_hf_cache_realpath",
|
||||
lambda path: str(embedder_snapshot) if Path(path) == embedder_path else str(path),
|
||||
)
|
||||
|
||||
def _row(repo_id: str, repo_path: Path):
|
||||
return model_common._local_model_info(
|
||||
scan_path = repo_path,
|
||||
load_path = repo_path,
|
||||
source = "hf_cache",
|
||||
model_format = "safetensors",
|
||||
model_id = repo_id,
|
||||
)
|
||||
|
||||
rows = local_inventory._filter_hidden_models(
|
||||
[_row("org/embedder", embedder_path), _row("org/chat-model", chat_path)]
|
||||
)
|
||||
|
||||
assert [row.model_id for row in rows] == ["org/chat-model"]
|
||||
|
||||
|
||||
def test_model_download_job_helpers_preserve_idle_shape():
|
||||
key = downloads._download_job_key("Org/Model", None)
|
||||
status = downloads._job_status(key)
|
||||
|
|
|
|||
|
|
@ -59,59 +59,12 @@ def _safe_is_dir(path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Hub repo id shape ("owner/name", no leading separator); anything else is
|
||||
# treated as a local filesystem path.
|
||||
_HF_REPO_ID_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*/[\w.\-]+$")
|
||||
|
||||
|
||||
def _is_hidden_model(*values: str | None) -> bool:
|
||||
"""True if any id/path is the RAG embedding model (EMBEDDING_MODEL or
|
||||
EMBED_GGUF_REPO basename) or the llama.cpp install validation probe
|
||||
(ggml-org/models / stories260K), so pickers hide them (GGUF and non-GGUF).
|
||||
None are usable chat models; the probe can be cached as a side effect of
|
||||
installing the prebuilt llama-server and otherwise sorts smallest, so it
|
||||
would be auto-selected. A local-path embedder is matched by exact resolved
|
||||
path only: a generic basename like "model" must not substring-hide
|
||||
unrelated chat models."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
needles = [
|
||||
# The validation probe's repo (matches the cached repo id) and its exact
|
||||
# filename (matches the on-disk path). The filename carries the .gguf so
|
||||
# it does not hide unrelated repos like ``user/stories260K-finetune-GGUF``.
|
||||
"ggml-org/models",
|
||||
"stories260k.gguf",
|
||||
]
|
||||
exact_paths: list[str] = []
|
||||
for model in (
|
||||
rag_config.effective_embedding_model(),
|
||||
rag_config.effective_gguf_repo(),
|
||||
):
|
||||
if _HF_REPO_ID_RE.match(model):
|
||||
needles.append(model.split("/")[-1].lower())
|
||||
else:
|
||||
resolved = _safe_resolve(Path(model).expanduser())
|
||||
if resolved:
|
||||
exact_paths.append(resolved.lower())
|
||||
for v in values:
|
||||
if not v:
|
||||
continue
|
||||
low = v.lower()
|
||||
if any(n in low for n in needles):
|
||||
return True
|
||||
if exact_paths:
|
||||
resolved = _safe_resolve(Path(v).expanduser())
|
||||
if resolved and resolved.lower() in exact_paths:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_resolve(path: Path) -> Optional[str]:
|
||||
"""resolve() to a string, or None when the path is inaccessible."""
|
||||
try:
|
||||
return str(path.resolve())
|
||||
except OSError:
|
||||
return None
|
||||
# Shared with the hub inventory scans; keep the private aliases so existing
|
||||
# importers (core.inference.local_model_resolver, tests) stay valid.
|
||||
from utils.hidden_models import (
|
||||
_safe_resolve,
|
||||
is_hidden_model as _is_hidden_model,
|
||||
)
|
||||
|
||||
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
|
|
@ -853,7 +806,7 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
|
|||
key = lambda item: (item.updated_at or 0),
|
||||
reverse = True,
|
||||
)
|
||||
return [m for m in models if not _is_hidden_model(m.id, m.path)]
|
||||
return [m for m in models if not _is_hidden_model(m.id, m.model_id, m.path)]
|
||||
|
||||
|
||||
@router.get("/local", response_model = LocalModelListResponse)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|||
|
||||
from auth.authentication import get_current_subject
|
||||
from auth.storage import rotate_preview_link_secret
|
||||
from core.rag.config import default_gguf_repo, effective_gguf_repo
|
||||
from loggers import get_logger
|
||||
from utils.utils import safe_error_detail, log_and_http_error
|
||||
from utils.personalization_settings import (
|
||||
|
|
@ -263,14 +264,18 @@ class EmbeddingModelPayload(BaseModel):
|
|||
|
||||
class EmbeddingModelResponse(BaseModel):
|
||||
embedding_model: str
|
||||
embedding_gguf_repo: str
|
||||
default_embedding_model: str
|
||||
default_embedding_gguf_repo: str
|
||||
is_custom: bool
|
||||
|
||||
|
||||
def _embedding_model_response() -> EmbeddingModelResponse:
|
||||
return EmbeddingModelResponse(
|
||||
embedding_model = get_rag_embedding_model(),
|
||||
embedding_gguf_repo = effective_gguf_repo(),
|
||||
default_embedding_model = default_embedding_model(),
|
||||
default_embedding_gguf_repo = default_gguf_repo(),
|
||||
is_custom = get_stored_embedding_model() is not None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -120,12 +120,151 @@ def test_is_hidden_model_hides_validation_probe_everywhere():
|
|||
assert models_route._is_hidden_model(
|
||||
None, "/hf/models--ggml-org--models/snapshots/abc/tinyllamas/stories260K.gguf"
|
||||
)
|
||||
# A Windows-style snapshot path must match too, even on a POSIX interpreter
|
||||
# (the filename check splits on both separators).
|
||||
assert models_route._is_hidden_model(
|
||||
r"C:\Users\u\.cache\huggingface\hub\models--ggml-org--models\snapshots\abc\tinyllamas\stories260K.gguf"
|
||||
)
|
||||
assert not models_route._is_hidden_model("unsloth/gemma-3-270m-it-GGUF")
|
||||
# The exact-filename needle must not hide a real repo that merely
|
||||
# references stories260K in its name.
|
||||
assert not models_route._is_hidden_model("user/stories260K-finetune-GGUF")
|
||||
|
||||
|
||||
def test_is_hidden_model_matches_repo_ids_exactly(monkeypatch):
|
||||
"""A custom embedder with a generic basename is hidden by EXACT repo-id
|
||||
match only, so unrelated cached repos that merely contain the basename stay
|
||||
visible. Regression: substring basename matching hid real chat models like
|
||||
``user/model-chat`` from the On Device inventory."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
|
||||
|
||||
# The exact embedder repo and its GGUF companion are hidden.
|
||||
assert models_route._is_hidden_model("org/model")
|
||||
assert models_route._is_hidden_model("org/model-GGUF")
|
||||
# Unrelated repos that merely contain "model" must NOT be hidden.
|
||||
assert not models_route._is_hidden_model("user/model-chat")
|
||||
assert not models_route._is_hidden_model("org/model-instruct")
|
||||
assert not models_route._is_hidden_model("acme/remodelled-chat")
|
||||
# The validation probe stays hidden regardless of embedder config.
|
||||
assert models_route._is_hidden_model("ggml-org/models")
|
||||
|
||||
|
||||
def test_is_hidden_model_matches_repo_derived_local_paths(monkeypatch):
|
||||
"""Match exact repo-derived cache and LM Studio paths."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
|
||||
|
||||
assert models_route._is_hidden_model(
|
||||
"/cache/models--org--model/snapshots/abc/model.safetensors"
|
||||
)
|
||||
assert models_route._is_hidden_model(
|
||||
r"C:\Users\u\.cache\huggingface\hub\models--org--model-GGUF\snapshots\abc"
|
||||
)
|
||||
assert models_route._is_hidden_model("/lm-studio/org/model-GGUF/model-Q8_0.gguf")
|
||||
assert not models_route._is_hidden_model("/lm-studio/user/model-chat/model-Q8_0.gguf")
|
||||
assert not models_route._is_hidden_model("/cache/models--org--model-instruct")
|
||||
|
||||
|
||||
def test_is_hidden_model_prefers_existing_relative_path(monkeypatch, tmp_path):
|
||||
"""Prefer an existing relative path over repo-id syntax."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
embedder = tmp_path / "models" / "embedder"
|
||||
embedder.mkdir(parents = True)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "models/embedder")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/embedder-GGUF")
|
||||
|
||||
assert models_route._is_hidden_model(str(embedder))
|
||||
|
||||
|
||||
def test_is_hidden_model_keeps_stale_default_embedder_hidden(monkeypatch):
|
||||
"""Keep default embedders hidden after a settings change."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/custom")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/custom-GGUF")
|
||||
|
||||
assert models_route._is_hidden_model("unsloth/bge-small-en-v1.5")
|
||||
assert models_route._is_hidden_model("unsloth/bge-small-en-v1.5-GGUF")
|
||||
assert models_route._is_hidden_model("/models/bge-small-en-v1.5")
|
||||
assert models_route._is_hidden_model("/models/bge-small-en-v1.5-F16.gguf")
|
||||
assert models_route._is_hidden_model(r"C:\models\bge-small-en-v1.5-Q8_0.gguf")
|
||||
# Repo IDs still use exact matching, and similar local basenames must have
|
||||
# a real separator after the static default name.
|
||||
assert not models_route._is_hidden_model("user/bge-small-en-v1.5-chat")
|
||||
assert not models_route._is_hidden_model("/models/bge-small-en-v1.50")
|
||||
|
||||
|
||||
def test_is_hidden_model_keeps_env_default_hidden_after_override(monkeypatch):
|
||||
"""A persisted override must not expose the deployment's env default."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
monkeypatch.delenv("RAG_EMBED_GGUF_REPO", raising = False)
|
||||
monkeypatch.setattr(rag_config, "EMBEDDING_MODEL", "org/env-default")
|
||||
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/custom")
|
||||
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/custom-GGUF")
|
||||
|
||||
assert models_route._is_hidden_model("org/env-default")
|
||||
assert models_route._is_hidden_model("org/env-default-GGUF")
|
||||
assert models_route._is_hidden_model("org/custom")
|
||||
assert models_route._is_hidden_model("org/custom-GGUF")
|
||||
assert not models_route._is_hidden_model("org/env-default-chat")
|
||||
|
||||
|
||||
def test_hidden_models_importable_without_heavy_model_stack():
|
||||
"""The hub cache scanner imports ``is_hidden_model`` at module scope, so it
|
||||
must not drag in ``utils/models/__init__`` (the model-config + checkpoint
|
||||
stack). Verify in a clean interpreter that importing the helper touches
|
||||
neither ``utils.models`` nor those heavy submodules, and still classifies
|
||||
the probe."""
|
||||
import os
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
backend = Path(__file__).resolve().parents[1]
|
||||
code = textwrap.dedent(
|
||||
"""
|
||||
import sys
|
||||
|
||||
class _Blocker:
|
||||
_blocked = (
|
||||
"utils.models",
|
||||
"utils.models.model_config",
|
||||
"utils.models.checkpoints",
|
||||
)
|
||||
|
||||
def find_spec(self, name, path=None, target=None):
|
||||
if name in self._blocked:
|
||||
raise ImportError("blocked heavy import: " + name)
|
||||
return None
|
||||
|
||||
sys.meta_path.insert(0, _Blocker())
|
||||
from utils.hidden_models import is_hidden_model
|
||||
|
||||
loaded = sorted(m for m in sys.modules if m.startswith("utils.models"))
|
||||
assert not loaded, loaded
|
||||
assert is_hidden_model("ggml-org/models") is True
|
||||
assert is_hidden_model("unsloth/gemma-3-270m-it-GGUF") is False
|
||||
print("HIDDEN_MODELS_IMPORT_OK")
|
||||
"""
|
||||
)
|
||||
env = dict(os.environ, PYTHONPATH = str(backend))
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
env = env,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "HIDDEN_MODELS_IMPORT_OK" in proc.stdout
|
||||
|
||||
|
||||
def test_list_cached_gguf_hides_llama_validation_probe(monkeypatch, tmp_path):
|
||||
"""The ggml-org/models / stories260K install validation probe can land in
|
||||
the HF cache as a side effect of installing the prebuilt llama-server.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ def client(monkeypatch):
|
|||
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
|
||||
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
|
||||
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"effective_gguf_repo",
|
||||
lambda: f"{saved.get('model', 'unsloth/default-embed')}-GGUF",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"default_gguf_repo",
|
||||
lambda: "unsloth/default-embed-GGUF",
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(settings.router)
|
||||
|
|
@ -257,6 +267,13 @@ def test_clean_repo_saves_under_force(client, monkeypatch):
|
|||
r = c.put("/embedding-model", json = {"embedding_model": "acme/clean-embed", "force": True})
|
||||
assert r.status_code == 200
|
||||
assert saved.get("model") == "acme/clean-embed"
|
||||
assert r.json() == {
|
||||
"embedding_model": "acme/clean-embed",
|
||||
"embedding_gguf_repo": "acme/clean-embed-GGUF",
|
||||
"default_embedding_model": "unsloth/default-embed",
|
||||
"default_embedding_gguf_repo": "unsloth/default-embed-GGUF",
|
||||
"is_custom": True,
|
||||
}
|
||||
|
||||
|
||||
def test_load_sink_refuses_flagged_model(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -53,3 +53,10 @@ def test_custom_model_overrides_default_and_derives_gguf(settings_store, monkeyp
|
|||
|
||||
assert ems.reset_rag_embedding_model() == rag_config.EMBEDDING_MODEL
|
||||
assert ems.get_stored_embedding_model() is None
|
||||
|
||||
|
||||
def test_env_default_derives_its_gguf_companion(monkeypatch):
|
||||
monkeypatch.delenv("RAG_EMBED_GGUF_REPO", raising = False)
|
||||
monkeypatch.setattr(rag_config, "EMBEDDING_MODEL", "org/env-default-embedder")
|
||||
|
||||
assert rag_config.default_gguf_repo() == "org/env-default-embedder-GGUF"
|
||||
|
|
|
|||
|
|
@ -697,6 +697,10 @@ def test_index_excludes_hidden_models(tmp_path, monkeypatch):
|
|||
normal.write_bytes(b"x" * 32)
|
||||
probe = tmp_path / "stories260K.gguf" # llama.cpp install-validation probe
|
||||
probe.write_bytes(b"x" * 32)
|
||||
embedder = tmp_path / "embedding-Q8_0.gguf"
|
||||
embedder.write_bytes(b"x" * 32)
|
||||
local_default_embedder = tmp_path / "bge-small-en-v1.5-F16.gguf"
|
||||
local_default_embedder.write_bytes(b"x" * 32)
|
||||
|
||||
def _info(mid, path):
|
||||
return SimpleNamespace(id = mid, path = str(path), model_id = mid, display_name = mid)
|
||||
|
|
@ -704,7 +708,22 @@ def test_index_excludes_hidden_models(tmp_path, monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_scan_models_dir",
|
||||
lambda *a, **k: [_info("org/Normal-GGUF", normal), _info("ggml-org/models", probe)],
|
||||
lambda *a, **k: [
|
||||
_info("org/Normal-GGUF", normal),
|
||||
_info("ggml-org/models", probe),
|
||||
SimpleNamespace(
|
||||
id = str(embedder),
|
||||
path = str(embedder),
|
||||
model_id = "unsloth/bge-small-en-v1.5-GGUF",
|
||||
display_name = "embedding-Q8_0",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id = str(local_default_embedder),
|
||||
path = str(local_default_embedder),
|
||||
model_id = None,
|
||||
display_name = local_default_embedder.name,
|
||||
),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models_route, "_scan_hf_cache", lambda *a, **k: [])
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: tmp_path)
|
||||
|
|
@ -713,6 +732,8 @@ def test_index_excludes_hidden_models(tmp_path, monkeypatch):
|
|||
index = resolver._index()
|
||||
assert "org/normal-gguf" in index # keys are normalized to lowercase
|
||||
assert "ggml-org/models" not in index
|
||||
assert "unsloth/bge-small-en-v1.5-gguf" not in index
|
||||
assert str(local_default_embedder).lower() not in index
|
||||
# And the hidden probe cannot be auto-switched to by name.
|
||||
resolver._scan = (0.0, {})
|
||||
assert resolver.resolve_local_gguf("ggml-org/models") is None
|
||||
|
|
@ -1729,6 +1750,8 @@ def test_index_advertises_alias_not_filesystem_path(tmp_path, monkeypatch):
|
|||
# host path in /v1/models, yet the model stays resolvable by that path too.
|
||||
from types import SimpleNamespace
|
||||
import routes.models as models_route
|
||||
from storage import studio_db
|
||||
import utils.paths as paths
|
||||
|
||||
gguf = tmp_path / "model-Q4_K_M.gguf"
|
||||
gguf.write_bytes(b"x" * 32)
|
||||
|
|
@ -1742,6 +1765,8 @@ def test_index_advertises_alias_not_filesystem_path(tmp_path, monkeypatch):
|
|||
monkeypatch.setattr(models_route, "_scan_hf_cache", lambda *a, **k: [])
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(models_route, "_is_hidden_model", lambda *a, **k: False)
|
||||
monkeypatch.setattr(paths, "lmstudio_model_dirs", lambda: [])
|
||||
monkeypatch.setattr(studio_db, "list_scan_folders", lambda: [])
|
||||
resolver._scan = (0.0, {})
|
||||
|
||||
# The advertised id is the alias, never the absolute path.
|
||||
|
|
|
|||
142
studio/backend/utils/hidden_models.py
Normal file
142
studio/backend/utils/hidden_models.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Infra-only model detection shared by the model routes and the hub
|
||||
inventory. Lives directly under ``utils`` (not ``utils.models``) so the hub
|
||||
cache scanner can import it without pulling in ``utils/models/__init__.py``,
|
||||
which eagerly loads the model-config/checkpoint stack, and without importing
|
||||
``routes.models`` (import-time side effects, would cycle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Hub repo id shape ("owner/name", no leading separator); anything else is
|
||||
# treated as a local filesystem path.
|
||||
_HF_REPO_ID_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*/[\w.\-]+$")
|
||||
|
||||
# The llama.cpp install-validation probe repo. Always hidden.
|
||||
_PROBE_REPO_ID = "ggml-org/models"
|
||||
# The probe's on-disk filename. Carries the ".gguf" so it stays specific and
|
||||
# does not hide unrelated repos like ``user/stories260K-finetune-GGUF``.
|
||||
_PROBE_FILENAME = "stories260k.gguf"
|
||||
# Keep previously cached defaults hidden after settings changes.
|
||||
_DEFAULT_EMBEDDING_REPO_IDS = {
|
||||
"unsloth/bge-small-en-v1.5",
|
||||
"unsloth/bge-small-en-v1.5-GGUF",
|
||||
}
|
||||
# Local copies do not always retain the repo id. Keep a narrow basename
|
||||
# fallback for Studio's static default embedder only; configured custom repos
|
||||
# remain exact-match-only.
|
||||
_DEFAULT_EMBEDDING_PATH_BASENAMES = {"bge-small-en-v1.5"}
|
||||
|
||||
|
||||
def _safe_resolve(path: Path) -> Optional[str]:
|
||||
"""resolve() to a string, or None when the path is inaccessible."""
|
||||
try:
|
||||
return str(path.resolve())
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _existing_resolved_path(value: str) -> Optional[str]:
|
||||
"""Resolve an existing local path."""
|
||||
path = Path(value).expanduser()
|
||||
try:
|
||||
if not path.exists():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return _safe_resolve(path)
|
||||
|
||||
|
||||
def _path_contains_repo_id(value: str, repo_ids: set[str]) -> bool:
|
||||
"""Match exact repo-derived path segments."""
|
||||
parts = [part for part in value.lower().replace("\\", "/").split("/") if part]
|
||||
for repo_id in repo_ids:
|
||||
owner, name = repo_id.split("/", 1)
|
||||
if f"models--{owner}--{name}" in parts:
|
||||
return True
|
||||
if any(
|
||||
parts[index] == owner and parts[index + 1] == name for index in range(len(parts) - 1)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _path_basename_is_default_embedder(value: str) -> bool:
|
||||
"""Match a default embedder folder or a suffixed local weight filename."""
|
||||
normalized = value.lower().replace("\\", "/").rstrip("/")
|
||||
basename = normalized.rsplit("/", 1)[-1]
|
||||
return any(
|
||||
basename == needle
|
||||
or any(basename.startswith(f"{needle}{separator}") for separator in ("-", "_", "."))
|
||||
for needle in _DEFAULT_EMBEDDING_PATH_BASENAMES
|
||||
)
|
||||
|
||||
|
||||
def is_hidden_model(*values: str | None) -> bool:
|
||||
"""True if any id/path is the RAG embedding model (the effective embedder
|
||||
or its GGUF companion repo) or the llama.cpp install validation probe
|
||||
(ggml-org/models / stories260K), so pickers hide them (GGUF and non-GGUF).
|
||||
None are usable chat models; the probe can be cached as a side effect of
|
||||
installing the prebuilt llama-server and otherwise sorts smallest, so it
|
||||
would be auto-selected.
|
||||
|
||||
Hub repo ids are matched EXACTLY (case-insensitive full "owner/name"), so a
|
||||
custom embedder with a generic basename like "org/model" cannot substring
|
||||
hide unrelated cached repos such as "user/model-chat" or "org/model-GGUF".
|
||||
Existing paths take precedence over the identical ``owner/name`` repo
|
||||
shape. Cache and LM Studio paths use exact repo-derived segments. Local
|
||||
copies of the static default embedder also use a boundary-aware basename
|
||||
fallback; configured custom repos never do."""
|
||||
from core.rag import config as rag_config
|
||||
|
||||
hidden_repo_ids = {
|
||||
_PROBE_REPO_ID.lower(),
|
||||
*(repo_id.lower() for repo_id in _DEFAULT_EMBEDDING_REPO_IDS),
|
||||
}
|
||||
exact_paths: list[str] = []
|
||||
for model in {
|
||||
rag_config.EMBEDDING_MODEL,
|
||||
rag_config.default_gguf_repo(),
|
||||
rag_config.effective_embedding_model(),
|
||||
rag_config.effective_gguf_repo(),
|
||||
}:
|
||||
existing_path = _existing_resolved_path(model)
|
||||
if existing_path:
|
||||
exact_paths.append(existing_path.lower())
|
||||
elif _HF_REPO_ID_RE.match(model):
|
||||
hidden_repo_ids.add(model.lower())
|
||||
else:
|
||||
resolved = _safe_resolve(Path(model).expanduser())
|
||||
if resolved:
|
||||
exact_paths.append(resolved.lower())
|
||||
for v in values:
|
||||
if not v:
|
||||
continue
|
||||
low = v.lower()
|
||||
if _HF_REPO_ID_RE.match(v):
|
||||
# A repo id ("owner/name"): match the hidden set exactly. It is
|
||||
# never a filesystem path, so skip the path/filename checks.
|
||||
if low in hidden_repo_ids:
|
||||
return True
|
||||
continue
|
||||
# Anything else is treated as a filesystem path (the cached snapshot
|
||||
# path, or a local model id). Match the probe by its exact filename and
|
||||
# any configured local-path embedder by exact resolved path. Split on
|
||||
# both separators so a Windows-style path ("...\\stories260K.gguf") is
|
||||
# matched even when this runs on a POSIX interpreter (and vice versa).
|
||||
if low.replace("\\", "/").rsplit("/", 1)[-1] == _PROBE_FILENAME:
|
||||
return True
|
||||
if _path_basename_is_default_embedder(v):
|
||||
return True
|
||||
if _path_contains_repo_id(v, hidden_repo_ids):
|
||||
return True
|
||||
if exact_paths:
|
||||
resolved = _safe_resolve(Path(v).expanduser())
|
||||
if resolved and resolved.lower() in exact_paths:
|
||||
return True
|
||||
return False
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { loadEmbeddingModelSettings } from "@/features/settings";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useInventoryVersion } from "../stores/inventory-events";
|
||||
|
||||
/** Backend-resolved embedding repos that optimistic inventory rows must hide. */
|
||||
export function useHiddenEmbeddingModelIds(
|
||||
enabled: boolean,
|
||||
): ReadonlySet<string> {
|
||||
const inventoryVersion = useInventoryVersion();
|
||||
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: inventory invalidation must reload backend-resolved embedder ids
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadEmbeddingModelSettings()
|
||||
.then((settings) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setHiddenIds(
|
||||
new Set(
|
||||
[
|
||||
settings.embeddingModel,
|
||||
settings.embeddingGgufRepo,
|
||||
settings.defaultEmbeddingModel,
|
||||
settings.defaultEmbeddingGgufRepo,
|
||||
].map((value) => value.trim().toLowerCase()),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, inventoryVersion]);
|
||||
|
||||
return hiddenIds;
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ import { useDiscoverSearch } from "./hooks/use-discover-search";
|
|||
import { useFeedWriteBack } from "./hooks/use-feed-write-back";
|
||||
import { useHubFeed } from "./hooks/use-hub-feed";
|
||||
import { useHubModelVram } from "./hooks/use-hub-model-vram";
|
||||
import { useHiddenEmbeddingModelIds } from "./hooks/use-hidden-embedding-models";
|
||||
import { useModelsSelection } from "./hooks/use-models-selection";
|
||||
import {
|
||||
CHANNEL_TO_SECTION,
|
||||
|
|
@ -73,7 +74,10 @@ import {
|
|||
SECTION_TO_CHANNEL,
|
||||
findChannel,
|
||||
} from "./lib/channels";
|
||||
import { isHiddenModelId } from "./lib/hidden-models";
|
||||
import {
|
||||
isConfiguredHiddenModelId,
|
||||
isHiddenModelId,
|
||||
} from "./lib/hidden-models";
|
||||
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
|
||||
import { resolveOwnerProviderLogo } from "./lib/provider-logos";
|
||||
import {
|
||||
|
|
@ -386,6 +390,7 @@ export function ModelsPage() {
|
|||
useState<ModelFormatFilter>("all");
|
||||
const isDiscoverTab = tab === "discover";
|
||||
const isDatasetMode = resourceType === "datasets";
|
||||
const hiddenEmbeddingModelIds = useHiddenEmbeddingModelIds(!isDatasetMode);
|
||||
const urlSection = hubSearch.section ?? null;
|
||||
const isModelDiscover = isDiscoverTab && !isDatasetMode;
|
||||
const sectionChannelId: ChannelId | null = urlSection
|
||||
|
|
@ -700,6 +705,7 @@ export function ModelsPage() {
|
|||
return discoverRows.filter(
|
||||
(row) =>
|
||||
!isHiddenModelId(row.id) &&
|
||||
!isConfiguredHiddenModelId(hiddenEmbeddingModelIds, row.id) &&
|
||||
// The default feed only shows models with a provider logo.
|
||||
(!isFeedMode ||
|
||||
resolveOwnerProviderLogo(row.owner, row.repo) !== null) &&
|
||||
|
|
@ -714,6 +720,7 @@ export function ModelsPage() {
|
|||
);
|
||||
}, [
|
||||
discoverRows,
|
||||
hiddenEmbeddingModelIds,
|
||||
isDatasetMode,
|
||||
isFeedMode,
|
||||
effectiveDiscoverFormat,
|
||||
|
|
@ -739,7 +746,11 @@ export function ModelsPage() {
|
|||
effectiveCachedRows,
|
||||
effectiveLocalRows,
|
||||
)
|
||||
.filter((row) => !isHiddenModelId(row.id))
|
||||
.filter(
|
||||
(row) =>
|
||||
!isHiddenModelId(row.id) &&
|
||||
!isConfiguredHiddenModelId(hiddenEmbeddingModelIds, row.id),
|
||||
)
|
||||
.filter((row) => matchesFormat(row.result.isGguf, "gguf"))
|
||||
// Same fit filter as the main Discover list, so the feed carousel
|
||||
// honors the toggle too.
|
||||
|
|
@ -751,6 +762,7 @@ export function ModelsPage() {
|
|||
),
|
||||
[
|
||||
hubFeed.trending.results,
|
||||
hiddenEmbeddingModelIds,
|
||||
modelDiscoveryInventorySignature,
|
||||
fitOnDeviceOnly,
|
||||
gpu,
|
||||
|
|
@ -778,22 +790,29 @@ export function ModelsPage() {
|
|||
() => (isDiscoverTab ? [] : tokenizeQuery(deferredDebouncedQuery)),
|
||||
[isDiscoverTab, deferredDebouncedQuery],
|
||||
);
|
||||
// Hide infra models (e.g. the RAG embedder bge-small-en-v1.5) from the On
|
||||
// Device list like Discover, but reveal a row when a query matches it so the
|
||||
// user can confirm it is already downloaded.
|
||||
// Server cache rows already apply variant-aware infra hiding. Optimistic
|
||||
// rows are not server-confirmed, so apply the client filter first.
|
||||
const isVisibleInventoryRow = useCallback(
|
||||
(row: CachedInventoryRow | LocalInventoryRow) =>
|
||||
// Local rows can have a null repoId and an id that is a hash rather than
|
||||
// the file path/name, so also check path/title (the backend's
|
||||
// _is_hidden_model checks the on-disk path for the same reason).
|
||||
!isHiddenModelId(
|
||||
row.id,
|
||||
row.repoId,
|
||||
row.kind !== "cache" ? row.path : undefined,
|
||||
row.kind !== "cache" ? row.title : undefined,
|
||||
) ||
|
||||
(inventoryTokens.length > 0 && inventoryRowMatches(row, inventoryTokens)),
|
||||
[inventoryTokens],
|
||||
(row: CachedInventoryRow | LocalInventoryRow) => {
|
||||
if (row.kind === "cache") {
|
||||
return (
|
||||
!row.optimistic ||
|
||||
(!isHiddenModelId(row.id, row.repoId, row.cachePath) &&
|
||||
!isConfiguredHiddenModelId(
|
||||
hiddenEmbeddingModelIds,
|
||||
row.id,
|
||||
row.repoId,
|
||||
row.cachePath,
|
||||
))
|
||||
);
|
||||
}
|
||||
// Local rows may lack a repo id, so also check path and title.
|
||||
return (
|
||||
!isHiddenModelId(row.id, row.repoId, row.path, row.title) ||
|
||||
(inventoryTokens.length > 0 && inventoryRowMatches(row, inventoryTokens))
|
||||
);
|
||||
},
|
||||
[hiddenEmbeddingModelIds, inventoryTokens],
|
||||
);
|
||||
// Format filter is a deliberate scope narrowing, so hard-filter it out. The
|
||||
// text query instead drives dim-not-filter on On Device (see ModelsCatalog) so
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { cancelStagedModelDownload } from "./download-manager";
|
||||
export { bumpInventoryVersion } from "./stores/inventory-events";
|
||||
export {
|
||||
getHfToken,
|
||||
mirrorHfTokenInto,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type InventoryHintRow = {
|
|||
repo_id: string;
|
||||
size_bytes: number;
|
||||
partial?: boolean;
|
||||
optimistic?: boolean;
|
||||
};
|
||||
|
||||
export type InventoryHintReconciliation = {
|
||||
|
|
@ -41,6 +42,7 @@ function optimisticRow(hint: InventoryHint): InventoryHintRow {
|
|||
repo_id: hint.repoId,
|
||||
size_bytes: hint.bytes ?? 0,
|
||||
partial: false,
|
||||
optimistic: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -101,9 +103,14 @@ function mergeInventoryHint(
|
|||
if (idx === -1) {
|
||||
return [...rows, seed];
|
||||
}
|
||||
const serverRow = rows[idx];
|
||||
const merged = {
|
||||
...rows[idx],
|
||||
...seed,
|
||||
...serverRow,
|
||||
// A completed hint may arrive before a partial server scan catches up. In
|
||||
// that case keep the synthetic row non-runnable. A complete server row is
|
||||
// already authoritative even when its runnable-weight size is smaller than
|
||||
// the hint's full-snapshot byte count, so do not mark that merge optimistic.
|
||||
...(serverRow.partial ? seed : { optimistic: false }),
|
||||
size_bytes: Math.max(rowSizeBytes(rows[idx]), rowSizeBytes(seed)),
|
||||
};
|
||||
return [...rows.slice(0, idx), merged, ...rows.slice(idx + 1)];
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface CachedInventoryRow {
|
|||
libraryName?: string | null;
|
||||
quantMethod?: string | null;
|
||||
liveDownload?: boolean;
|
||||
optimistic?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalInventoryRow {
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ function liveDownloadInventoryRows(
|
|||
size_bytes: job.displayBytes,
|
||||
partial: true,
|
||||
partial_transport: null,
|
||||
optimistic: true,
|
||||
},
|
||||
modelFormat,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export function buildCachedInventoryRow(
|
|||
runtime?: string | null;
|
||||
format_variant?: string | null;
|
||||
capabilities?: BackendModelCapabilities | null;
|
||||
optimistic?: boolean;
|
||||
},
|
||||
fallbackFormat: ModelInventoryFormat,
|
||||
): CachedInventoryRow {
|
||||
|
|
@ -185,6 +186,15 @@ export function buildCachedInventoryRow(
|
|||
const inferredFromEndpoint =
|
||||
rawModelFormat === "unknown" && modelFormat !== "unknown";
|
||||
const requiresVariant = modelFormat === "gguf";
|
||||
const capabilities = normalizeCapabilities(
|
||||
inferredFromEndpoint ? null : row.capabilities,
|
||||
modelFormat,
|
||||
row.partial ?? false,
|
||||
requiresVariant,
|
||||
);
|
||||
if (row.optimistic) {
|
||||
capabilities.canChat = false;
|
||||
}
|
||||
return {
|
||||
kind: "cache",
|
||||
id:
|
||||
|
|
@ -202,12 +212,7 @@ export function buildCachedInventoryRow(
|
|||
modelFormat,
|
||||
),
|
||||
formatVariant: row.format_variant ?? null,
|
||||
capabilities: normalizeCapabilities(
|
||||
inferredFromEndpoint ? null : row.capabilities,
|
||||
modelFormat,
|
||||
row.partial ?? false,
|
||||
requiresVariant,
|
||||
),
|
||||
capabilities,
|
||||
bytes: row.size_bytes,
|
||||
cachePath: row.cache_path ?? null,
|
||||
partial: row.partial ?? false,
|
||||
|
|
@ -216,6 +221,7 @@ export function buildCachedInventoryRow(
|
|||
tags: row.tags,
|
||||
libraryName: row.library_name ?? null,
|
||||
quantMethod: row.quant_method ?? null,
|
||||
optimistic: row.optimistic,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Infra models hidden from every browse/preview list (Hub discover and the chat
|
||||
// model selector). Mirrors the backend `_is_hidden_model`: the RAG embedding
|
||||
// model and the llama.cpp validation probe are not usable chat models. Per-repo
|
||||
// file/download views are NOT filtered, so a reinstall still shows the model as
|
||||
// already downloaded.
|
||||
// Infra models hidden from browse/preview lists (Hub Discover, the chat model
|
||||
// selector, and local on-device rows). Mirrors the backend
|
||||
// `utils.hidden_models`: the RAG embedding model and the llama.cpp validation
|
||||
// probe are not usable chat models. Server-confirmed cache rows are trusted
|
||||
// because the backend applies variant-aware filtering. Optimistic cache rows
|
||||
// still use these needles until the server confirms them. Per-repo views are
|
||||
// not filtered, so reinstall flows still show downloaded files.
|
||||
const HIDDEN_NEEDLES = [
|
||||
"bge-small-en-v1.5", // RAG embedder: unsloth/bge-small-en-v1.5[-GGUF]
|
||||
"ggml-org/models", // llama.cpp validation probe repo
|
||||
|
|
@ -17,8 +19,20 @@ export function isHiddenModelId(
|
|||
...values: (string | null | undefined)[]
|
||||
): boolean {
|
||||
return values.some((v) => {
|
||||
if (!v) return false;
|
||||
if (!v) {
|
||||
return false;
|
||||
}
|
||||
const lower = v.toLowerCase();
|
||||
return HIDDEN_NEEDLES.some((needle) => lower.includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
/** Exact-match configured infra repos without hiding similarly named models. */
|
||||
export function isConfiguredHiddenModelId(
|
||||
configuredIds: ReadonlySet<string>,
|
||||
...values: (string | null | undefined)[]
|
||||
): boolean {
|
||||
return values.some(
|
||||
(value) => value != null && configuredIds.has(value.trim().toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { bumpInventoryVersion } from "@/features/hub";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
export type EmbeddingModelSettings = {
|
||||
embeddingModel: string;
|
||||
embeddingGgufRepo: string;
|
||||
defaultEmbeddingModel: string;
|
||||
defaultEmbeddingGgufRepo: string;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -14,8 +17,12 @@ type ApiEmbeddingModelSettings = {
|
|||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
embedding_model: string;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
embedding_gguf_repo: string;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
default_embedding_model: string;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
default_embedding_gguf_repo: string;
|
||||
// biome-ignore lint/style/useNamingConvention: API schema
|
||||
is_custom: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -30,7 +37,9 @@ export class EmbeddingModelBlockedError extends Error {}
|
|||
function fromApi(settings: ApiEmbeddingModelSettings): EmbeddingModelSettings {
|
||||
return {
|
||||
embeddingModel: settings.embedding_model,
|
||||
embeddingGgufRepo: settings.embedding_gguf_repo,
|
||||
defaultEmbeddingModel: settings.default_embedding_model,
|
||||
defaultEmbeddingGgufRepo: settings.default_embedding_gguf_repo,
|
||||
isCustom: settings.is_custom,
|
||||
};
|
||||
}
|
||||
|
|
@ -75,7 +84,9 @@ export async function updateEmbeddingModelSettings(
|
|||
await readFastApiError(res, "Failed to save embedding model"),
|
||||
);
|
||||
}
|
||||
return fromApi(await res.json());
|
||||
const settings = fromApi(await res.json());
|
||||
bumpInventoryVersion();
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function resetEmbeddingModelSettings(): Promise<EmbeddingModelSettings> {
|
||||
|
|
@ -87,5 +98,7 @@ export async function resetEmbeddingModelSettings(): Promise<EmbeddingModelSetti
|
|||
await readFastApiError(res, "Failed to reset embedding model"),
|
||||
);
|
||||
}
|
||||
return fromApi(await res.json());
|
||||
const settings = fromApi(await res.json());
|
||||
bumpInventoryVersion();
|
||||
return settings;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { SettingsDialog } from "./settings-dialog";
|
||||
export { loadEmbeddingModelSettings } from "./api/embedding-model";
|
||||
export {
|
||||
loadPersonalization,
|
||||
savePersonalization,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue