unsloth/studio/backend/tests/test_cached_gguf_routes.py
Lee Jackson 7f0910fcc6
Add interactive Agents command builder (#7312)
* Add Agents settings tab for unsloth start

Adds a Settings > Agents tab documenting the `unsloth start` command:
quickstart, supported agents with click-to-copy commands, model
selection, common options, remote Studio setup, argument pass-through,
and a dry-run preview. Agent CLIs found on PATH are badged as installed.

Also removes the "New" badge from the System and Chat tabs.

* Use official brand logos for agents, invert Ollama and OpenRouter in dark mode

Claude Code and OpenAI Codex now use the Anthropic and OpenAI logos from
the provider-logos registry; agents without an official asset keep the
monogram tile. Also inverts the Ollama and OpenRouter logos in dark mode
so their monochrome marks stay visible.

* Title Agents tab "Agents (unsloth start)" and move it below Connections

The in-tab header now reads "Agents (unsloth start)" while the sidebar
label stays "Agents". Reorders the tab to sit below Connections.

* Address review: guard PATH detection, fix copy timeout, OS-aware remote snippet

- Only probe agent PATH in the desktop app on a loopback backend, so
  Installed badges are not driven by a remote server's environment.
- Show the "none found" note only when detection actually ran and
  returned empty, not when the call failed.
- Share one copy hook that resets its timeout on rapid clicks and clears
  it on unmount.
- Render the Remote Studio snippet with PowerShell syntax on Windows.
- Note that --no-launch can still load a model when --model is set.
- Drop unused quickstart translation keys.

* Add interactive Agents command builder

* Add local subagent command guidance

* Add official coding agent icons

* Use client OS for remote commands, fix copy a11y and model wording (#7303)

- Pick the remote snippet shell from the client platform, not the server deviceType
- Single-line the model examples so they paste in POSIX, PowerShell and cmd
- Split the pass-through block into independent one-command copies
- Derive detection visibility instead of clearing state in the effect
- Announce copy success to assistive tech
- Correct the quickstart/model copy: bare start uses the loaded model

* Shell-quote the model, forward the HF token, and fix the quant placeholder

- Quote the --model value in the generated and subagent commands so a local
  path with spaces or metacharacters stays a single argument (client-OS aware)
- Pass the saved Hugging Face token to listGgufVariants so gated repos resolve
- Show 'No separate quantization' instead of a stuck 'Loading quantizations...'
  when a model has no variants; clear the failure once a later request succeeds

* Fix Agents command discovery and routing

* Unsloth start improvements: download progress, server reuse, and safe model switching (#7313)

* Improve unsloth start runtime lifecycle

* Remove speculative Gemma prompt override

* Polish model download progress output

* Refine unsloth start status output

* Clarify unsloth readiness banner

* Clarify model reuse and switching output

* Queue model switches behind active inference

* Tighten unsloth start model switching

* Reduce model switch bookkeeping

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix Studio re-exec compatibility

* Recheck sidecar reservation after inference drain

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pass start marker through child environment

* Fix key redaction, switch-waiter ordering, and stop/messaging gaps for PR #7313

- Redact minted sk-unsloth keys from the startup-failure log tail: the early
  key marker lands in the server log before the model load finishes, so a
  load-phase crash printed a live key to the terminal
- Deregister a finished switch waiter before releasing the swap gate so a
  swap on another event loop cannot count it as still queued and unload the
  model the finished request is about to generate against
- Warn on same-repo quant switches: an explicit variant replaces the resident
  weights for every attached session, but the repo ids match so no switch
  warning was printed
- Note the agent exit code when it is nonzero so the server keep-alive
  message does not read as a successful session
- Use taskkill /T in unsloth studio stop so llama-server children stop too

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten comments in start, studio, and inference changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>

* Unsloth start: add local subagents for Claude Code, Codex, OpenCode and Pi (#7326)

Bring the local-subagent support onto main. The original change (#7316) merged
into the stacked pr/daniel-unsloth-start-audit branch rather than main, and #7313
reached main via squash, so these files never landed on main.

Adds --as-subagent for claude, codex, opencode and pi: the parent agent keeps its
own cloud model while a locally served GGUF is registered as a delegated subagent,
using ephemeral per-session config that never touches the user's real agent config.

* Fix Agents builder defaults and flag validation

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix Agents variant and provider fallbacks

* Fix local model and Pi subagent edge cases

* Agents tab: flag the Codex row when the loaded model is not GGUF

* Agents tab: target the active Studio server, wrap narrow rows, index the tab's search terms

* Agents tab: build copied commands from the browser-reachable Studio and show the key placeholder

* Preserve cache load ids and path variants in built commands for PR #7312

A GGUF outside the active Hugging Face cache only loads by its snapshot
path, so keep that load_id for --model while still listing the row by repo
id. Path based models carry their quant in --gguf-variant rather than a
":variant" suffix, and the active selection now keeps the variant inference
status reports for them.

* Agents tab: index the intro for agent-name searches and keep long commands inside the panel

* List GGUF variants from the cache the command loads from for PR #7312

A snapshot outside the active Hugging Face cache was offering the remote
variant list, so a quant absent from that snapshot could be selected and
the generated command would fail to load it.

* Agents tab: omit --api-key so the CLI can replay a saved key for the base

* Agents tab: label the indexed heading rows and fall back to the active desktop API base

* Agents tab: name every supported agent in the indexed intro for PR #7303

* Send the cached GGUF load path and fix the agents tab search targets for PR #7312

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the agents tab comments for PR #7303

* Build the agents tab example commands from the active Studio base for PR #7303

* Keep the resident model on its active cache load for PR #7312

* Tighten the agents tab and cached GGUF comments for PR #7312

* Take the agent command shell from the Studio host for PR #7303

* Stop emitting snapshot paths as --model and keep unsloth start searchable for PR #7312

* Pick the command shell from where the CLI runs for PR #7303

* Match a path load by its advertised id and follow the resident model for PR #7312

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep an explicit quantization and retire superseded native-grant labels for PR #7312

* Scope the remembered quant, stop following unloaded models and keep local GGUF paths for PR #7312

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stop shadowing the path classifier, match snapshot ordering and sequence status polls for PR #7312

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Release stale native-grant picks, keep local GGUF identities and index snapshot aliases for PR #7312

* Index inactive-cache snapshots, widen local GGUF detection and clear retired quants for PR #7312

* Classify cached repos by snapshot, merge repo ids case-insensitively and keep loose GGUFs variantless for PR #7312

* Fix snapshot alias, partial split and mmproj-only handling for PR #7312

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Trust scanned model_format and drop incomplete snapshot ids for PR #7312

* Exclude mmproj and partial downloads, keep path case and drop duplicate scan for PR #7312

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Restrict revision aliases and require complete snapshot variants for PR #7312

* Index revisions individually and hide partial variants for PR #7312

---------

Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: oobabooga <oobabooga4@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-26 17:09:19 -07:00

1199 lines
41 KiB
Python

# 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 asyncio
import sys
import types
from pathlib import Path
from types import SimpleNamespace
# Keep this test runnable without optional logging deps.
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
from hub.services.models import gguf_variants as GV
def _repo(
repo_id: str,
files: list[SimpleNamespace],
repo_path: Path,
*,
revisions: list[SimpleNamespace] | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
repo_id = repo_id,
repo_type = "model",
repo_path = repo_path,
revisions = revisions or [SimpleNamespace(files = files)],
)
def _file(
name: str,
size_on_disk: int,
*,
blob_path: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
file_name = name,
size_on_disk = size_on_disk,
blob_path = blob_path,
)
def test_iter_gguf_paths_matches_extension_case_insensitively(tmp_path):
nested = tmp_path / "snapshots" / "rev"
nested.mkdir(parents = True)
lower = nested / "Q4_K_M.gguf"
upper = nested / "Q8_0.GGUF"
other = nested / "README.md"
lower.write_text("a")
upper.write_text("b")
other.write_text("c")
result = sorted(path.name for path in models_route._iter_gguf_paths(tmp_path))
assert result == ["Q4_K_M.gguf", "Q8_0.GGUF"]
def test_legacy_hf_scan_uses_snapshot_path_for_inactive_cache(tmp_path):
repo = tmp_path / "models--Org--Model"
snapshot = repo / "snapshots" / "revision"
snapshot.mkdir(parents = True)
[row] = models_route._scan_hf_cache(tmp_path, active_cache = False)
assert row.model_id == "Org/Model"
assert row.id == str(snapshot.resolve())
assert row.path == str(snapshot.resolve())
def test_collect_local_models_scans_previous_cache(monkeypatch, tmp_path):
active = tmp_path / "active"
previous = tmp_path / "previous"
active.mkdir()
snapshot = previous / "models--Org--Previous" / "snapshots" / "revision"
snapshot.mkdir(parents = True)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
monkeypatch.setattr("utils.hf_cache_settings.known_hf_hub_caches", lambda: [active, previous])
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
rows = models_route.collect_local_models(tmp_path / "models")
previous_row = next(row for row in rows if row.model_id == "Org/Previous")
assert previous_row.id == str(snapshot.resolve())
def test_collect_local_models_prefers_complete_previous_copy(monkeypatch, tmp_path):
active = tmp_path / "active"
previous = tmp_path / "previous"
active_partial = active / "models--Org--Model" / "blobs" / "abc.incomplete"
active_partial.parent.mkdir(parents = True)
active_partial.write_bytes(b"partial")
snapshot = previous / "models--Org--Model" / "snapshots" / "revision"
snapshot.mkdir(parents = True)
(snapshot / "model.safetensors").write_bytes(b"complete")
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
monkeypatch.setattr(
"utils.hf_cache_settings.known_hf_hub_caches",
lambda: [active, previous],
)
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
rows = models_route.collect_local_models(tmp_path / "models")
[row] = [row for row in rows if row.model_id == "Org/Model"]
assert row.id == str(snapshot.resolve())
assert row.partial is False
assert row.active_cache is False
def test_list_cached_gguf_reports_snapshot_load_id_for_inactive_cache(monkeypatch, tmp_path):
"""Only a repo outside the active cache needs a snapshot load_id."""
active = tmp_path / "active"
snapshot = tmp_path / "legacy" / "models--Org--Away" / "snapshots" / "rev"
snapshot.mkdir(parents = True)
(snapshot / "Q4_K_M.gguf").write_bytes(b"\0")
away = _repo(
"Org/Away",
[],
tmp_path / "legacy" / "models--Org--Away",
revisions = [
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000)], snapshot_path = snapshot),
],
)
here = _repo("Org/Here", [_file("Q4_K_M.gguf", 6_000)], active / "models--Org--Here")
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [away, here])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = {
c["repo_id"]: c
for c in asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
}
assert rows["Org/Away"]["load_id"] == str(snapshot)
assert "load_id" not in rows["Org/Here"]
def test_list_cached_gguf_load_id_follows_snapshot_dir_mtime(monkeypatch, tmp_path):
"""Pick the snapshot variant discovery reads: newest directory, not newest blob."""
import os
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Multi"
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
for path in (older, newer):
path.mkdir(parents = True)
(older / "Q4_K_M.gguf").write_bytes(b"\0")
(newer / "Q8_0.gguf").write_bytes(b"\0")
os.utime(older, (1_000, 1_000))
os.utime(newer, (2_000, 2_000))
repo = _repo(
"Org/Multi",
[],
repo_dir,
revisions = [
# The older directory holds the newer blob, which is what diverges.
SimpleNamespace(
files = [_file("Q4_K_M.gguf", 5_000, blob_path = "b1")], snapshot_path = older
),
SimpleNamespace(files = [_file("Q8_0.gguf", 6_000, blob_path = "b2")], snapshot_path = newer),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr(
models_route, "_blob_mtime", lambda f: 9_000 if f.blob_path == "b1" else 1.0
)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert rows[0]["load_id"] == str(newer)
def test_list_cached_gguf_load_id_skips_partial_split_snapshot(monkeypatch, tmp_path):
"""A half-downloaded split quant must not beat an older snapshot that can load."""
import os
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Split"
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
for path in (older, newer):
path.mkdir(parents = True)
(older / "Model-Q8_0.gguf").write_bytes(b"\0")
# Only part 1 of 3 landed before the download was interrupted.
(newer / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
os.utime(older, (1_000, 1_000))
os.utime(newer, (2_000, 2_000))
repo = _repo(
"Org/Split",
[],
repo_dir,
revisions = [
SimpleNamespace(files = [_file("Model-Q8_0.gguf", 5_000)], snapshot_path = older),
SimpleNamespace(
files = [_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000)], snapshot_path = newer
),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert rows[0]["load_id"] == str(older)
def test_list_cached_gguf_omits_load_id_when_no_snapshot_is_complete(monkeypatch, tmp_path):
"""With only a half-downloaded split quant, fall back to the repo id, not a path."""
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Torn"
snapshot = repo_dir / "snapshots" / "rev"
snapshot.mkdir(parents = True)
(snapshot / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
repo = _repo(
"Org/Torn",
[],
repo_dir,
revisions = [
SimpleNamespace(
files = [_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000)], snapshot_path = snapshot
),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert "load_id" not in rows[0]
def test_list_cached_gguf_skips_snapshot_with_one_incomplete_variant(monkeypatch, tmp_path):
"""A good quant beside a half-downloaded one is still not a safe load target."""
import os
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Mixed"
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
for path in (older, newer):
path.mkdir(parents = True)
(older / "Model-Q8_0.gguf").write_bytes(b"\0")
# rev-b has a complete Q8_0 AND a half-downloaded split Q4_K_M. The picker
# enumerates the whole directory, so it would offer the broken one.
(newer / "Model-Q8_0.gguf").write_bytes(b"\0")
(newer / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
os.utime(older, (1_000, 1_000))
os.utime(newer, (2_000, 2_000))
repo = _repo(
"Org/Mixed",
[],
repo_dir,
revisions = [
SimpleNamespace(files = [_file("Model-Q8_0.gguf", 5_000)], snapshot_path = older),
SimpleNamespace(
files = [
_file("Model-Q8_0.gguf", 5_000),
_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000),
],
snapshot_path = newer,
),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert rows[0]["load_id"] == str(older)
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monkeypatch, tmp_path):
repo = _repo(
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
[_file("Q4_K_M.gguf", 5_000), _file("README.md", 10)],
tmp_path / "models--HauhauCS--Gemma",
)
scan = SimpleNamespace(repos = [repo])
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
}
]
def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_path):
repo = _repo(
"Org/Model-Without-Suffix",
[_file("Q8_0.GGUF", 7_000)],
tmp_path / "models--Org--Model-Without-Suffix",
)
scan = SimpleNamespace(repos = [repo])
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/Model-Without-Suffix",
"size_bytes": 7_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
}
]
def test_is_hidden_model_hides_validation_probe_everywhere():
"""Every picker (model list, local, cached GGUF, cached models) gates on
_is_hidden_model, so hiding the probe here hides it in the search menu too.
Cover both forms callers pass: the reconstructed repo id and the on-disk
snapshot path."""
assert models_route._is_hidden_model("ggml-org/models")
assert models_route._is_hidden_model("ggml-org/models/tinyllamas/stories260K.gguf")
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_hides_dictation_models(tmp_path):
assert models_route._is_hidden_model("unsloth/whisper-tiny")
assert models_route._is_hidden_model("unsloth/whisper-base")
assert models_route._is_hidden_model("unsloth/whisper-small")
assert models_route._is_hidden_model("unsloth/whisper-large-v3-turbo")
assert models_route._is_hidden_model(
"/hf/models--unsloth--whisper-large-v3/snapshots/abc/model.safetensors"
)
assert not models_route._is_hidden_model("user/whisper-finetune")
assert not models_route._is_hidden_model(
"C:\\cache\\models--unsloth--whisper-small-finetune\\model.safetensors"
)
custom = tmp_path / "custom-whisper"
custom.mkdir()
(custom / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
(custom / "model.safetensors").write_bytes(b"weights")
assert models_route._is_hidden_model(
"user/custom-checkpoint",
str(custom / "model.safetensors"),
)
named_only = tmp_path / "whisper-finetune"
named_only.mkdir()
(named_only / "config.json").write_text('{"model_type": "llama"}')
assert not models_route._is_hidden_model("user/whisper-finetune", str(named_only))
def test_list_cached_models_hides_custom_whisper_by_config(monkeypatch, tmp_path):
# Regression: the legacy /cached-models picker must pass the snapshot path so
# the config check hides a custom (non-curated) Whisper checkpoint; a bare
# repo id cannot ("user/whisper-finetune" is not in the curated set).
repo_path = tmp_path / "models--user--whisper-finetune"
snap = repo_path / "snapshots" / "abc"
snap.mkdir(parents = True)
(snap / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
(snap / "model.safetensors").write_bytes(b"weights")
captured: list = []
real_hidden = models_route._is_hidden_model
def spy(*values):
captured.append(values)
return real_hidden(*values)
monkeypatch.setattr(models_route, "_is_hidden_model", spy)
repo = _repo(
"user/whisper-finetune",
[SimpleNamespace(file_name = "model.safetensors", size_on_disk = 10)],
repo_path,
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
result = asyncio.run(
models_route.list_cached_models(current_subject = "test-user", hf_token = None)
)
# The route passed the snapshot path (not just the repo id) ...
assert any(str(repo_path) in values for values in captured)
# ... so the custom Whisper checkpoint is hidden from the chat picker.
assert result["cached"] == []
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.
It is not a chat model (it sorts smallest and would be auto-selected), so
pickers must hide it while keeping real cached models."""
probe = _repo(
"ggml-org/models",
[_file("tinyllamas/stories260K.gguf", 1_000)],
tmp_path / "models--ggml-org--models",
)
real = _repo(
"unsloth/gemma-3-270m-it-GGUF",
[_file("gemma-3-270m-it-UD-Q4_K_XL.gguf", 200_000)],
tmp_path / "models--unsloth--gemma-3-270m-it-GGUF",
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [probe, real])]
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
repo_ids = [c["repo_id"] for c in result["cached"]]
assert "ggml-org/models" not in repo_ids
assert "unsloth/gemma-3-270m-it-GGUF" in repo_ids
def test_list_cached_gguf_skips_repos_without_positive_gguf_size(monkeypatch, tmp_path):
missing = _repo(
"Org/ReadmeOnly",
[_file("README.md", 10)],
tmp_path / "models--Org--ReadmeOnly",
)
zero = _repo(
"Org/ZeroSize",
[_file("Q4_K_M.gguf", 0)],
tmp_path / "models--Org--ZeroSize",
)
scan = SimpleNamespace(repos = [missing, zero])
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == []
def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(monkeypatch, tmp_path):
smaller = _repo(
"Org/Dupe",
[_file("Q4_K_M.gguf", 2_000)],
tmp_path / "models--Org--Dupe-a",
)
larger = _repo(
"org/dupe",
[_file("Q4_K_M.gguf", 5_000), _file("Q6_K.gguf", 1_000)],
tmp_path / "models--Org--Dupe-b",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [
SimpleNamespace(repos = [smaller]),
SimpleNamespace(repos = [larger]),
],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "org/dupe",
"size_bytes": 6_000,
"cache_path": str(larger.repo_path),
"has_vision": False,
}
]
def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp_path):
shared = "blobs/shared-q4"
repo = _repo(
"Org/SharedBlobRepo",
[],
tmp_path / "models--Org--SharedBlobRepo",
revisions = [
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
],
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/SharedBlobRepo",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
}
]
def test_list_cached_models_skips_non_suffix_repo_when_gguf_files_exist(monkeypatch, tmp_path):
mixed = _repo(
"Org/MixedRepo",
[
_file("Q4_K_M.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MixedRepo",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mixed])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
assert result["cached"] == []
def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypatch, tmp_path):
"""Mixed repo still surfaces in cached-gguf as a GGUF download."""
mixed = _repo(
"Org/MixedRepo",
[
_file("Q4_K_M.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MixedRepo",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mixed])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/MixedRepo",
"size_bytes": 5_000,
"cache_path": str(mixed.repo_path),
"has_vision": False,
}
]
def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
"""``size_on_disk = None`` (partial download) is treated as zero, not a
TypeError from ``sum()`` that wipes the response."""
partial = _repo(
"Org/PartialDownload",
[_file("Q4_K_M.gguf", None), _file("Q6_K.gguf", 5_000)],
tmp_path / "models--Org--PartialDownload",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [partial])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/PartialDownload",
"size_bytes": 5_000,
"cache_path": str(partial.repo_path),
"has_vision": False,
}
]
def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypatch, tmp_path):
"""One repo raising during classification must not poison the response."""
class _ExplodingRepo:
repo_id = "Org/Broken"
repo_type = "model"
repo_path = tmp_path / "models--Org--Broken"
@property
def revisions(self):
raise RuntimeError("boom")
healthy = _repo(
"Org/Healthy",
[_file("Q4_K_M.gguf", 5_000)],
tmp_path / "models--Org--Healthy",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [_ExplodingRepo(), healthy])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/Healthy",
"size_bytes": 5_000,
"cache_path": str(healthy.repo_path),
"has_vision": False,
}
]
def test_list_cached_gguf_skips_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
"""A repo whose only ``.gguf`` is an mmproj vision adapter is not a GGUF
repo: mmproj is filtered out, leaving zero variants."""
mmproj_only = _repo(
"Org/MmprojOnly",
[
_file("mmproj-Q8_0.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MmprojOnly",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mmproj_only])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == []
def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
"""A safetensors repo with an auxiliary mmproj adapter still surfaces in
cached-models as a normal model."""
mmproj_aux = _repo(
"Org/MmprojAux",
[
_file("mmproj-Q8_0.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MmprojAux",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mmproj_aux])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
assert result["cached"] == [{"repo_id": "Org/MmprojAux", "size_bytes": 15_000}]
def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeypatch, tmp_path):
"""A vision GGUF repo (main weight + mmproj) is a GGUF repo; reported size
is the main weight only, since mmproj is filtered at classification."""
vision_repo = _repo(
"Org/VisionGguf",
[
_file("Q4_K_M.gguf", 5_000),
_file("mmproj-Q8_0.gguf", 1_000),
],
tmp_path / "models--Org--VisionGguf",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [vision_repo])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/VisionGguf",
"size_bytes": 5_000,
"cache_path": str(vision_repo.repo_path),
"has_vision": True,
}
]
def _gfile(name: str, size: int, mtime: float) -> SimpleNamespace:
"""A cached file carrying a Hugging Face ``blob_last_modified`` timestamp."""
return SimpleNamespace(
file_name = name,
size_on_disk = size,
blob_path = None,
blob_last_modified = mtime,
)
def test_all_hf_cache_scans_uses_shared_inventory(monkeypatch, tmp_path):
from hub.utils import inventory_scan
active = SimpleNamespace(
repos = [_repo("Org/Active", [_file("Q4_K_M.gguf", 5_000)], tmp_path / "active")]
)
monkeypatch.setattr(inventory_scan, "all_hf_cache_scans", lambda: [active])
scans = models_route._all_hf_cache_scans()
assert scans == [active]
# End-to-end: the endpoint still returns the active cache's repo.
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [active])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/Active",
"size_bytes": 5_000,
"cache_path": str(tmp_path / "active"),
"has_vision": False,
}
]
def test_list_cached_gguf_sorts_newest_first_grouping_by_latest_quant(monkeypatch, tmp_path):
"""Downloaded is ordered newest-first, and a multi-quant repo is placed by
its most recently downloaded quant (``last_modified`` = newest quant)."""
older = _repo(
"Org/Older",
[_gfile("Older-Q4_K_M.gguf", 5_000, 1_000.0)],
tmp_path / "models--Org--Older",
)
newer = _repo(
"Org/Newer",
[
_gfile("Newer-Q4_K_M.gguf", 5_000, 2_000.0),
_gfile("Newer-Q8_0.gguf", 9_000, 3_000.0), # newest quant in the repo
],
tmp_path / "models--Org--Newer",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [older, newer])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert [c["repo_id"] for c in result["cached"]] == ["Org/Newer", "Org/Older"]
assert result["cached"][0]["last_modified"] == 3_000.0
assert result["cached"][1]["last_modified"] == 1_000.0
def test_list_cached_gguf_dedupe_keeps_newest_timestamp(monkeypatch, tmp_path):
"""Same repo in two caches with equal size keeps the newest last_modified,
regardless of scan order."""
older = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 1_000.0)], tmp_path / "a")
newer = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 9_000.0)], tmp_path / "b")
for scans in ([older, newer], [newer, older]): # both orders
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda s = scans: [SimpleNamespace(repos = [s[0]]), SimpleNamespace(repos = [s[1]])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "t"))
assert len(result["cached"]) == 1
assert result["cached"][0]["last_modified"] == 9_000.0
def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_path):
"""The per-quant 'downloaded' flag is driven by the real weight file in a
single snapshot; an mmproj vision adapter (matching a quant label) must
not make that quant appear downloaded."""
variants = [
SimpleNamespace(
filename = "model-Q4_K_M.gguf",
quant = "Q4_K_M",
display_label = None,
size_bytes = 10_000,
),
SimpleNamespace(
filename = "model-F16.gguf",
quant = "F16",
display_label = None,
size_bytes = 20_000,
),
]
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda repo_id, hf_token = None: (variants, True, []),
)
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10_000) # real weight, fully present
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # mmproj adapter, label "F16"
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
)
)
flags = {v.quant: v.downloaded for v in result.variants}
assert flags["Q4_K_M"] is True
assert flags["F16"] is False
def test_gguf_variants_route_scopes_local_probe_to_selected_cache(monkeypatch, tmp_path):
snapshot = tmp_path / "inactive" / "models--org--repo" / "snapshots" / "rev"
snapshot.mkdir(parents = True)
calls = []
async def scoped_variants(repo_id, **kwargs):
calls.append((repo_id, kwargs))
return SimpleNamespace(
repo_id = repo_id,
variants = [],
has_vision = False,
default_variant = None,
)
context_calls = []
monkeypatch.setattr(GV, "get_gguf_variants_response", scoped_variants)
monkeypatch.setattr(
models_route,
"_read_native_context_length",
lambda model, *, is_local: context_calls.append((model, is_local)) or 8192,
)
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo",
prefer_local_cache = True,
local_path = str(snapshot),
hf_token = None,
current_subject = "test-user",
)
)
assert calls == [
(
"org/repo",
{
"prefer_local_cache": True,
"local_path": str(snapshot),
"hf_token": None,
},
)
]
assert context_calls == [(str(snapshot), True)]
assert result.context_length == 8192
def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
siblings = [
SimpleNamespace(rfilename = "model-Q4_K_M-be.gguf", size = 100),
SimpleNamespace(rfilename = "model-Q4_K_M.gguf", size = 10),
]
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda repo_id, hf_token = None: (
[
SimpleNamespace(
filename = "model-Q4_K_M.gguf",
quant = "Q4_K_M",
display_label = None,
size_bytes = 10,
)
],
False,
siblings,
),
)
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
)
)
assert [(v.quant, v.filename, v.size_bytes, v.downloaded) for v in result.variants] == [
("Q4_K_M", "model-Q4_K_M.gguf", 10, True)
]
def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, tmp_path):
variants = [
SimpleNamespace(
filename = "model-Q4_K_M.gguf",
quant = "Q4_K_M",
display_label = None,
size_bytes = 10,
),
]
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda repo_id, hf_token = None: (variants, False, []),
)
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 10)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
)
)
assert result.variants[0].downloaded is False
def test_legacy_gguf_progress_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(repo_id, *, variant, expected_bytes, hf_token):
calls.append((repo_id, variant, expected_bytes, hf_token))
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
monkeypatch.setattr(
"hub.services.models.downloads.get_gguf_download_progress_response",
shared,
)
result = asyncio.run(
models_route.get_gguf_download_progress(
repo_id = "org/repo",
variant = "Q4_K_M",
expected_bytes = 20,
hf_token = "token",
current_subject = "test-user",
)
)
assert result["progress"] == 0.5
assert calls == [("org/repo", "Q4_K_M", 20, "token")]
def test_legacy_model_progress_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(repo_id, *, hf_token):
calls.append((repo_id, hf_token))
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
monkeypatch.setattr(
"hub.services.models.downloads.get_download_progress_response",
shared,
)
result = asyncio.run(
models_route.get_download_progress(
repo_id = "org/repo",
hf_token = "token",
current_subject = "test-user",
)
)
assert result["progress"] == 0.5
assert calls == [("org/repo", "token")]
def test_legacy_delete_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(
repo_id,
variant,
hf_token,
cache_path = None,
):
calls.append((repo_id, variant, hf_token, cache_path))
return {"status": "deleted", "repo_id": repo_id}
monkeypatch.setattr(
"hub.services.models.deletion.delete_cached_model_response",
shared,
)
result = asyncio.run(
models_route.delete_cached_model(
repo_id = "org/repo",
variant = None,
cache_path = "/data/hf/hub",
hf_token = "token",
current_subject = "test-user",
)
)
assert result == {"status": "deleted", "repo_id": "org/repo"}
assert calls == [("org/repo", None, "token", "/data/hf/hub")]