diff --git a/studio/backend/core/inference/local_model_resolver.py b/studio/backend/core/inference/local_model_resolver.py index 9e3eaeda3f..e6014f442d 100644 --- a/studio/backend/core/inference/local_model_resolver.py +++ b/studio/backend/core/inference/local_model_resolver.py @@ -147,10 +147,16 @@ def _build_index() -> dict[str, _LocalGgufEntry]: ) from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir, lmstudio_model_dirs from utils.hf_cache_settings import known_hf_hub_caches + from core.inference.model_ids import public_model_id index: dict[str, _LocalGgufEntry] = {} seen_hf: set[str] = set() + try: + active_root = str(Path(_resolve_hf_cache_dir()).resolve()) + except Exception: + active_root = None + def _scan_hf_once(directory) -> list: if directory is None: return [] @@ -162,7 +168,13 @@ def _build_index() -> dict[str, _LocalGgufEntry]: if rp in seen_hf: return [] seen_hf.add(rp) - return _scan_hf_cache(directory) + # Only the active cache loads by repo id. Say so, or an inactive repo is + # indexed under an id it cannot load by, and its snapshot basename (what + # /v1/models advertises once loaded by path) is never a key at all. + # No format classification here: nothing on this path reads model_format, + # and its recursive walk would duplicate the one _local_gguf_entry already + # does per snapshot, on the request path. + return _scan_hf_cache(directory, active_cache = rp == active_root, classify_format = False) except Exception as exc: # a missing/malformed root must skip, never crash the index logger.debug("auto-switch: skipping HF cache dir %r: %s", directory, exc) return [] @@ -220,12 +232,61 @@ def _build_index() -> dict[str, _LocalGgufEntry]: continue # Index every alias (including the path) so a client can resolve by any of # them, even though only the non-path loader_id is advertised. - for key in (raw_id, getattr(info, "model_id", None), getattr(info, "display_name", None)): + for key in ( + raw_id, + getattr(info, "model_id", None), + getattr(info, "display_name", None), + public_model_id(raw_id), + ): if key: index.setdefault(key.strip().lower(), entry) + # Other revisions of the same repo resolve to their own weights, so a pin on + # one keeps working after Hugging Face writes a newer snapshot. + for name, sibling_entry in _sibling_revision_entries(raw_id, loader_id): + index.setdefault(name.strip().lower(), sibling_entry) return index +def _sibling_revision_entries(raw_id: str, loader_id: str): + """Yield ``(revision_name, entry)`` for the repo's OTHER cached revisions. + + An inactive-cache repo carries its snapshot path as the id, and /v1/models + advertises only that directory's basename once loaded, so anything durable + pinned to it (a subagent config) holds one revision hash. Hugging Face writes a + new snapshot dir on every update, and the scan emits a single entry per repo + pointed at the newest one, so that pin would otherwise stop resolving and drop + through to whatever model is loaded. + + Each revision gets an entry for its OWN directory rather than an alias onto the + scanned one: aliasing would redirect a pin that names an older complete revision + onto a newer half-downloaded snapshot and break a request that works today. + Incomplete revisions are skipped for the same reason. + + Sibling names are only revisions inside a real cache repo + (``/models--org--name/snapshots/``). A scan folder that merely happens + to be called ``snapshots`` holds unrelated models, and treating those as + revisions would silently serve one model in place of another. + """ + from pathlib import Path + from types import SimpleNamespace + + snapshots = Path(raw_id).parent + if snapshots.name != "snapshots" or not snapshots.parent.name.startswith("models--"): + return + from routes.models import snapshot_variants_all_complete + + try: + siblings = [p for p in snapshots.iterdir() if p.is_dir() and p.name != Path(raw_id).name] + except OSError: + return + for sibling in siblings: + if not snapshot_variants_all_complete(str(sibling)): + continue + entry = _local_gguf_entry(loader_id, SimpleNamespace(path = str(sibling))) + if entry is not None: + yield sibling.name, entry + + def _index() -> dict[str, _LocalGgufEntry]: global _scan # Build under the lock so concurrent callers with an expired cache don't all diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index df6725c9c9..2c2929f8e6 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -143,6 +143,12 @@ class GgufVariantDetail(BaseModel): update_available: bool = Field( False, description = "Whether a newer version of this variant is available on HF" ) + partial: bool = Field( + False, + description = "Whether this variant is an interrupted download. The hub service " + "already computes it; carry it through so callers can hide a quant whose shards " + "are incomplete instead of offering one that cannot load.", + ) class GgufVariantsResponse(BaseModel): diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index ed83a12f48..fd779590e6 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -314,7 +314,11 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca try: if not child.is_dir(): continue - has_gguf = any(child.glob("*.gguf")) + gguf_names = [p.name for p in child.glob("*.gguf")] + has_gguf = bool(gguf_names) + # mmproj alone is a vision adapter, not servable weights, so it decides + # presence but never format (same rule as _dir_model_format). + has_main_gguf = any(_is_main_gguf_filename(n) for n in gguf_names) has_non_gguf_weights = _has_non_gguf_weights(child) has_config = (child / "config.json").exists() or ( child / "adapter_config.json" @@ -332,7 +336,7 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca # A folder whose only weights are .gguf is GGUF-format even when it also # ships a config.json (common for HF GGUF repos); such folders often lack # a -GGUF suffix, so surface the format for the UI's GGUF classification. - model_format = "gguf" if has_gguf and not has_non_gguf_weights else None + model_format = "gguf" if has_main_gguf and not has_non_gguf_weights else None found.append( LocalModelInfo( id = str(child), @@ -348,7 +352,8 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca for gguf_file in models_dir.glob("*.gguf"): if limit is not None and len(found) >= limit: break - if gguf_file.is_file(): + # A standalone mmproj is a vision adapter, not servable weights. + if gguf_file.is_file() and _is_main_gguf_filename(gguf_file.name): try: updated_at = gguf_file.stat().st_mtime except OSError: @@ -367,7 +372,12 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca return found -def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalModelInfo]: +def _scan_hf_cache( + cache_dir: Path, + *, + active_cache: bool = True, + classify_format: bool = True, +) -> List[LocalModelInfo]: if not cache_dir.exists() or not cache_dir.is_dir(): return [] @@ -392,13 +402,23 @@ def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalM partial = partial or hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir) load_id = model_id + snapshot = _resolve_hf_cache_realpath(repo_dir) if not active_cache: - load_id = _resolve_hf_cache_realpath(repo_dir) or str(repo_dir.resolve()) + load_id = snapshot or str(repo_dir.resolve()) + # Classify from the snapshot's own weights. A GGUF repo without a -GGUF + # suffix is common, and leaving this unset makes every consumer guess from + # the name; the snapshot is already resolved just above. + model_format = ( + _dir_model_format(Path(snapshot), recursive = True) + if snapshot and classify_format + else None + ) found.append( LocalModelInfo( id = load_id, model_id = model_id, display_name = model_id.split("/")[-1], + model_format = model_format, path = load_id if not active_cache else str(repo_dir), source = "hf_cache", active_cache = active_cache, @@ -409,16 +429,30 @@ def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalM return found -def _dir_model_format(path: Path) -> Optional[str]: +def _dir_model_format(path: Path, recursive: bool = False) -> Optional[str]: """Return ``"gguf"`` for a directory whose only weights are ``.gguf`` files. LM Studio and custom GGUF folders frequently lack a ``-GGUF`` name suffix, so the UI relies on this hint to route them through the GGUF load path - rather than treating them as plain local checkpoints. + rather than treating them as plain local checkpoints. A directory whose only + ``.gguf`` is an mmproj vision adapter is not one: the variant selector drops + mmproj, so that path would find nothing to serve. + + ``recursive`` is for HF cache snapshots, which keep split quants in per-quant + subdirectories: a flat glob sees no ``.gguf`` there and would report the + snapshot as non-GGUF, hiding every sharded repo from the GGUF pickers. It looks + one level down rather than walking the tree, because that is where split quants + live and ``/api/models/local`` is async: an unbounded ``rglob`` per repo would + have to exhaust every non-GGUF snapshot before concluding there is no GGUF, + blocking the event loop on a large cache. """ try: - if not any(path.glob("*.gguf")): - return None + found = path.glob("*.gguf") + if not any(_is_main_gguf_filename(p.name) for p in found): + if not recursive: + return None + if not any(_is_main_gguf_filename(p.name) for p in path.glob("*/*.gguf")): + return None return None if _has_non_gguf_weights(path) else "gguf" except OSError: return None @@ -455,7 +489,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: for child in lm_dir.iterdir(): try: if not child.is_dir(): - if child.suffix == ".gguf" and child.is_file(): + if _is_main_gguf_filename(child.name) and child.is_file(): try: updated_at = child.stat().st_mtime except OSError: @@ -518,7 +552,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: updated_at = updated_at, ), ) - elif model_dir.suffix == ".gguf" and model_dir.is_file(): + elif _is_main_gguf_filename(model_dir.name) and model_dir.is_file(): try: updated_at = model_dir.stat().st_mtime except OSError: @@ -2792,6 +2826,7 @@ async def get_gguf_variants( ), downloaded = bool(v.downloaded), update_available = bool(getattr(v, "update_available", False)), + partial = bool(getattr(v, "partial", False)), ) for v in response.variants ], @@ -3016,11 +3051,80 @@ def _repo_gguf_last_modified(repo_info) -> float: return latest +def snapshot_variants_all_complete(snapshot: str) -> bool: + """True when every quant the variant lister would advertise from *snapshot* is + fully on disk. + + One complete quant is not enough: the picker enumerates the whole directory, so a + half-downloaded split quant sitting beside a good one still gets offered and the + generated command asks llama-server for shards that are absent. Both sides derive + their labels from ``extract_quant_label`` over paths relative to the snapshot, so + the sets are directly comparable. + """ + from hub.utils import inventory_scan + from hub.utils.gguf import list_local_gguf_variants + + try: + variants, _ = list_local_gguf_variants(snapshot) + offered = {v.quant for v in variants if getattr(v, "quant", None)} + if not offered: + return False + return offered <= inventory_scan._completed_gguf_variants(Path(snapshot)) + except Exception: + return False + + +def _repo_gguf_load_id(repo_info, active_root: Optional[Path]) -> Optional[str]: + """Snapshot dir holding the newest primary GGUF, for a repo outside the active + hub cache that does not resolve by id. ``None`` when the id works or no + snapshot is recorded, since the repo dir itself is not loadable. + """ + repo_path = getattr(repo_info, "repo_path", None) + if repo_path is None or active_root is None: + return None + try: + if repo_path.parent.resolve(strict = False) == active_root: + return None + except (OSError, RuntimeError, ValueError): + pass + # Order by snapshot directory mtime, matching hub.utils.gguf.iter_hf_cache_snapshots, + # which is what variant discovery reads. Blob mtimes would disagree with it whenever + # Hugging Face reuses an older blob in a newer snapshot, and the command would then + # name a snapshot that does not hold the quant the picker offered. + candidates: List[tuple[float, str]] = [] + for revision in repo_info.revisions: + snapshot = getattr(revision, "snapshot_path", None) + if snapshot is None: + continue + if not any(_is_main_gguf_filename(f.file_name) for f in revision.files): + continue + try: + mtime = Path(snapshot).stat().st_mtime + except OSError: + mtime = 0.0 + candidates.append((mtime, str(snapshot))) + candidates.sort(key = lambda c: c[0], reverse = True) + # Newest first, but skip one holding only part of a split quant: an interrupted + # download would otherwise beat an older snapshot that can still load. Scanning + # stops at the first usable snapshot, so the usual case walks one directory. + for _, snapshot in candidates: + if snapshot_variants_all_complete(snapshot): + return snapshot + # Nothing complete anywhere: publishing a half-downloaded snapshot would put that + # path in the copied command and fail on load. Drop the id so the repo id is used, + # which fetches the missing shards instead. + return None + + @router.get("/cached-gguf") async def list_cached_gguf(current_subject: str = Depends(get_current_subject)): """List GGUF repos downloaded to HF cache, legacy Unsloth cache, and HF default cache.""" try: cache_scans = _all_hf_cache_scans() + try: + active_root = _resolve_hf_cache_dir().resolve(strict = False) + except Exception: + active_root = None seen_lower: dict[str, dict] = {} for hf_cache in cache_scans: @@ -3046,6 +3150,9 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)): "cache_path": str(repo_info.repo_path), "has_vision": _repo_has_mmproj(repo_info), } + load_id = _repo_gguf_load_id(repo_info, active_root) + if load_id: + row["load_id"] = load_id # Keep the newest timestamp across duplicate caches; # attach only when known so absent rows sort as oldest. lm = max(last_modified, (existing or {}).get("last_modified", 0.0)) diff --git a/studio/backend/tests/test_cached_gguf_routes.py b/studio/backend/tests/test_cached_gguf_routes.py index 6f2c672002..68b181dbdc 100644 --- a/studio/backend/tests/test_cached_gguf_routes.py +++ b/studio/backend/tests/test_cached_gguf_routes.py @@ -126,6 +126,185 @@ def test_collect_local_models_prefers_complete_previous_copy(monkeypatch, tmp_pa 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", diff --git a/studio/backend/tests/test_local_model_format.py b/studio/backend/tests/test_local_model_format.py index b569163cc8..17990359f2 100644 --- a/studio/backend/tests/test_local_model_format.py +++ b/studio/backend/tests/test_local_model_format.py @@ -46,6 +46,68 @@ def test_dir_model_format_gguf_only(tmp_path): assert models_route._dir_model_format(d) == "gguf" +def test_dir_model_format_mmproj_only_is_not_gguf(tmp_path): + # A lone vision adapter has nothing servable: the variant selector drops mmproj. + d = tmp_path / "model" + _touch(d / "mmproj-F16.gguf") + assert models_route._dir_model_format(d) is None + + +def test_dir_model_format_mmproj_beside_weights_is_still_gguf(tmp_path): + d = tmp_path / "model" + _touch(d / "mmproj-F16.gguf") + _touch(d / "model-Q4_K_M.gguf") + assert models_route._dir_model_format(d) == "gguf" + + +def test_dir_model_format_recursive_sees_split_quant_subdirs(tmp_path): + # HF cache snapshots keep split quants in per-quant subdirs. A flat glob reports + # no GGUF there, which would hide every sharded repo from the GGUF pickers. + d = tmp_path / "snapshot" + _touch(d / "UD-Q4_K_XL" / "model-00001-of-00002.gguf") + assert models_route._dir_model_format(d) is None + assert models_route._dir_model_format(d, recursive = True) == "gguf" + + +def test_dir_model_format_recursive_ignores_mmproj_only_subdirs(tmp_path): + d = tmp_path / "snapshot" + _touch(d / "mmproj" / "mmproj-F16.gguf") + assert models_route._dir_model_format(d, recursive = True) is None + + +def test_scan_models_dir_mmproj_only_folder_is_not_gguf(tmp_path): + # Same rule as _dir_model_format, applied by the parallel ./models scanner. + _touch(tmp_path / "vision" / "mmproj-F16.gguf") + _touch(tmp_path / "real" / "model-Q4_K_M.gguf") + formats = {m.display_name: m.model_format for m in models_route._scan_models_dir(tmp_path)} + assert formats["vision"] is None + assert formats["real"] == "gguf" + + +def test_scan_models_dir_skips_standalone_mmproj_file(tmp_path): + # A loose mmproj-*.gguf is a vision adapter with no weights to serve, so it must + # not be offered as a model the way a loose primary GGUF is. + _touch(tmp_path / "mmproj-F16.gguf") + _touch(tmp_path / "model-Q4_K_M.gguf") + names = {m.display_name for m in models_route._scan_models_dir(tmp_path)} + assert names == {"model-Q4_K_M"} + + +def test_scan_lmstudio_dir_skips_standalone_mmproj_file(tmp_path): + _touch(tmp_path / "mmproj-F16.gguf") + _touch(tmp_path / "model-Q4_K_M.gguf") + names = {m.display_name for m in models_route._scan_lmstudio_dir(tmp_path)} + assert names == {"model-Q4_K_M"} + + +def test_scan_lmstudio_dir_skips_mmproj_under_publisher(tmp_path): + # LM Studio's publisher/model.gguf layout classifies on a separate branch. + _touch(tmp_path / "Publisher" / "mmproj-F16.gguf") + _touch(tmp_path / "Publisher" / "model-Q4_K_M.gguf") + names = {m.display_name for m in models_route._scan_lmstudio_dir(tmp_path)} + assert names == {"model-Q4_K_M"} + + def test_dir_model_format_gguf_with_config_is_still_gguf(tmp_path): # A config.json alongside the .gguf must not flip it to non-GGUF. d = tmp_path / "model" diff --git a/studio/backend/tests/test_openai_auto_switch.py b/studio/backend/tests/test_openai_auto_switch.py index 9c6c20e6b6..190d51db8f 100644 --- a/studio/backend/tests/test_openai_auto_switch.py +++ b/studio/backend/tests/test_openai_auto_switch.py @@ -1108,7 +1108,7 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch monkeypatch.setattr( models_route, "_scan_hf_cache", - lambda d: scanned.append(("hf", str(Path(d).resolve()))) or [], + lambda d, **_: scanned.append(("hf", str(Path(d).resolve()))) or [], ) monkeypatch.setattr( models_route, @@ -1337,6 +1337,56 @@ def test_hf_cache_entry_loads_from_local_snapshot_path(tmp_path): # ── review round 5: concurrent-swap, repo-id identity, /v1/models id, gate, 503 ── +def _revision_pair(root, complete: bool): + """Two revisions of one cache repo; the newer one is optionally half-downloaded.""" + snaps = root / "models--org--Repo" / "snapshots" + old, new = snaps / "rev-old", snaps / "rev-new" + for path in (old, new): + path.mkdir(parents = True) + (old / "model-Q8_0.gguf").write_bytes(b"GGUF stub") + name = "model-Q4_K_M.gguf" if complete else "model-Q4_K_M-00001-of-00003.gguf" + (new / name).write_bytes(b"GGUF stub") + return old, new + + +def test_sibling_revision_resolves_to_its_own_weights(tmp_path): + # /v1/models advertises only the snapshot dir name, so a durable pin holds one + # revision hash. A newer snapshot must not strand it, and the old revision must + # resolve to ITS OWN directory rather than be redirected onto the newest. + old, new = _revision_pair(tmp_path, complete = True) + + found = dict(resolver._sibling_revision_entries(str(new), "org/Repo")) + + assert "rev-old" in found + assert found["rev-old"].load_path == str(old) + + +def test_incomplete_sibling_revision_is_not_indexed(tmp_path): + # A half-downloaded revision cannot load, so naming it must not resolve to it. + old, _new = _revision_pair(tmp_path, complete = False) + # Point the scan at the complete one; the partial sibling is the candidate here. + found = dict(resolver._sibling_revision_entries(str(old), "org/Repo")) + + assert "rev-new" not in found + + +def test_sibling_revisions_ignore_a_scan_folder_named_snapshots(tmp_path): + # A user scan folder called "snapshots" holds unrelated models, not revisions of + # one repo; treating them as revisions would silently serve model-a as model-b. + snaps = tmp_path / "snapshots" + for name in ("model-a", "model-b"): + (snaps / name).mkdir(parents = True) + (snaps / name / "model-Q4_K_M.gguf").write_bytes(b"GGUF stub") + + found = dict(resolver._sibling_revision_entries(str(snaps / "model-a"), "model-a")) + + assert found == {} + + +def test_sibling_revisions_skip_plain_repo_ids(): + assert dict(resolver._sibling_revision_entries("org/Repo-GGUF", "org/Repo-GGUF")) == {} + + def test_already_loaded_by_repo_id_is_not_reswapped(monkeypatch): # A model loaded normally has model_identifier == repo id, but the resolver # returns the concrete load path. A request for that repo must count as already diff --git a/studio/frontend/public/agent-logos/hermes.svg b/studio/frontend/public/agent-logos/hermes.svg new file mode 100644 index 0000000000..33992d3525 --- /dev/null +++ b/studio/frontend/public/agent-logos/hermes.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/frontend/public/agent-logos/openclaw.svg b/studio/frontend/public/agent-logos/openclaw.svg new file mode 100644 index 0000000000..e8587c5c59 --- /dev/null +++ b/studio/frontend/public/agent-logos/openclaw.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/studio/frontend/public/agent-logos/opencode-dark.svg b/studio/frontend/public/agent-logos/opencode-dark.svg new file mode 100644 index 0000000000..8655c3d4a9 --- /dev/null +++ b/studio/frontend/public/agent-logos/opencode-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/studio/frontend/public/agent-logos/opencode-light.svg b/studio/frontend/public/agent-logos/opencode-light.svg new file mode 100644 index 0000000000..1783b6417a --- /dev/null +++ b/studio/frontend/public/agent-logos/opencode-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/studio/frontend/public/agent-logos/pi.svg b/studio/frontend/public/agent-logos/pi.svg new file mode 100644 index 0000000000..3f8a77bd1a --- /dev/null +++ b/studio/frontend/public/agent-logos/pi.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/studio/frontend/src/features/chat/api-provider-logo.tsx b/studio/frontend/src/features/chat/api-provider-logo.tsx index 7de9bea9a8..0eb85d5d3b 100644 --- a/studio/frontend/src/features/chat/api-provider-logo.tsx +++ b/studio/frontend/src/features/chat/api-provider-logo.tsx @@ -40,7 +40,6 @@ interface ApiProviderLogoProps { title?: string; } -// Monochrome logos vanish on a dark background. const DARK_INVERT_LOGOS = new Set(["openai", "ollama", "openrouter"]); /** Provider logo from `public/provider-logos/`; monochrome ones invert in dark mode. */ diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 4d123e98ab..4f558545ca 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -348,6 +348,9 @@ export interface LocalModelInfo { // Backend-detected weights format ("gguf" when known), so the UI can // classify scanned folders whose name lacks a -GGUF suffix. model_format?: string | null; + // Set when a cached snapshot holds an incomplete download, so consumers can skip + // weights that cannot load yet. + partial?: boolean; updated_at?: number | null; } diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 785212a2c4..0ce5096f60 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -11,9 +11,11 @@ export { fetchGgufStagedMetadata, getCachedModelPath, getInferenceStatus, + listCachedGguf, listChatAttachments, listGgufVariants, listLocalModels, + listModels, listRecommendedFolders, listScanFolders, loadModel, @@ -28,7 +30,11 @@ export { type LocalModelInfo, type ScanFolderInfo, } from "./api/chat-api"; -export type { GgufVariantDetail } from "./types/api"; +export type { + BackendModelDetails, + GgufVariantDetail, + InferenceStatusResponse, +} from "./types/api"; export { ChatSettingsPanel, ParamSlider, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 6c3e919efe..e6d3b79015 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -115,6 +115,8 @@ export interface GgufVariantDetail { download_size_bytes?: number; downloaded?: boolean; update_available?: boolean; + /** An interrupted download: some shards are missing, so it cannot load yet. */ + partial?: boolean; } export interface GgufVariantsResponse { @@ -169,7 +171,10 @@ export interface LoadModelResponse { max_context_length?: number | null; native_context_length?: number | null; supports_reasoning?: boolean; - reasoning_style?: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort"; + reasoning_style?: + | "enable_thinking" + | "reasoning_effort" + | "enable_thinking_effort"; reasoning_effort_levels?: string[]; reasoning_always_on?: boolean; supports_preserve_thinking?: boolean; @@ -220,7 +225,10 @@ export interface InferenceStatusResponse { } | null; requires_trust_remote_code?: boolean; supports_reasoning?: boolean; - reasoning_style?: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort"; + reasoning_style?: + | "enable_thinking" + | "reasoning_effort" + | "enable_thinking_effort"; reasoning_effort_levels?: string[]; reasoning_always_on?: boolean; supports_preserve_thinking?: boolean; @@ -389,7 +397,7 @@ export interface OpenAIChatCompletionsRequest { | "xhigh" | null; preserve_thinking?: boolean | null; - thinking?: {type: "disabled" | "enabled";} | null; + thinking?: { type: "disabled" | "enabled" } | null; enable_tools?: boolean | null; enabled_tools?: string[]; /** Local models + enable_tools only. */ diff --git a/studio/frontend/src/features/settings/components/usage-examples.tsx b/studio/frontend/src/features/settings/components/usage-examples.tsx index ade181f632..bba4498551 100644 --- a/studio/frontend/src/features/settings/components/usage-examples.tsx +++ b/studio/frontend/src/features/settings/components/usage-examples.tsx @@ -141,8 +141,9 @@ const AGENT_LABELS: Record = { }; const j = (s: string): string => JSON.stringify(s); -const shSingle = (s: string): string => s.replace(/'/g, "'\\''"); -const psSingle = (s: string): string => s.replace(/'/g, "''"); +// Inner escaping for a single-quoted argument (POSIX '\'' , PowerShell ''). +export const shSingle = (s: string): string => s.replace(/'/g, "'\\''"); +export const psSingle = (s: string): string => s.replace(/'/g, "''"); const toolsJson = TOOLS.map(j).join(", "); function bodyExtraLines(variant: Variant, indent: string): string[] { diff --git a/studio/frontend/src/features/settings/settings-search.ts b/studio/frontend/src/features/settings/settings-search.ts index f7366dba17..a5b008579c 100644 --- a/studio/frontend/src/features/settings/settings-search.ts +++ b/studio/frontend/src/features/settings/settings-search.ts @@ -104,13 +104,15 @@ export const SETTINGS_SEARCH_INDEX: Record = { "settings.apiKeys.accessTokens", ], agents: [ - // Heading and intro carry the searched terms ("unsloth start", agent names); titles do not. + // Every key needs a rendered data-settings-label, or a hit has nothing to scroll to. "settings.agents.title", "settings.agents.description", "settings.agents.intro", - "settings.agents.quickstart.title", - "settings.agents.supportedAgents.title", - "settings.agents.models.title", + "settings.agents.agent", + "settings.agents.model", + "settings.agents.quantization", + // subagent.title is deliberately absent: its label only mounts for the agents + // that support subagents, so a hit would have nothing to scroll to otherwise. "settings.agents.options.title", "settings.agents.remote.title", "settings.agents.passthrough.title", diff --git a/studio/frontend/src/features/settings/tabs/agents-tab.tsx b/studio/frontend/src/features/settings/tabs/agents-tab.tsx index 2ccd867c02..0e961688c8 100644 --- a/studio/frontend/src/features/settings/tabs/agents-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/agents-tab.tsx @@ -2,10 +2,42 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { getClientPlatform } from "@/components/tauri/window-titlebar"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { fetchDeviceType, usePlatformStore } from "@/config/env"; -import { useT } from "@/i18n"; +import { + type BackendModelDetails, + type GgufVariantDetail, + type InferenceStatusResponse, + type LocalModelInfo, + getInferenceStatus, + listCachedGguf, + listGgufVariants, + listLocalModels, + listModels, +} from "@/features/chat"; +import { useHfTokenStore } from "@/features/hub"; import type { TranslationKey } from "@/i18n"; +import { useT } from "@/i18n"; import { getApiBase, isTauri } from "@/lib/api-base"; +import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { Tick02Icon } from "@/lib/tick-icon"; import { cn } from "@/lib/utils"; @@ -15,18 +47,26 @@ import { Copy01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useEffect, useRef, useState } from "react"; -import { useChatRuntimeStore } from "@/features/chat"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ApiProviderLogo } from "../../chat/api-provider-logo"; -import { type CodingAgentsInfo, loadCodingAgents } from "../api/coding-agents"; +import { loadCodingAgents } from "../api/coding-agents"; import { buildAgentCommand, isLoopbackHost, normalizeHost, } from "../components/agent-command"; import { SettingsSection } from "../components/settings-section"; +import { psSingle, shSingle } from "../components/usage-examples"; const DOCS_URL = "https://unsloth.ai/docs/integrations/unsloth-start"; +const EXAMPLE_MODEL_REPO = "unsloth/gemma-4-E4B-it-GGUF"; +const EXAMPLE_MODEL_VARIANT = "UD-Q4_K_XL"; +const MODEL_RESULT_LIMIT = 7; +const STATUS_POLL_MS = 5000; +const HUGGING_FACE_REPO_PATTERN = /^[^/\\:\s]+\/[^/\\:\s]+$/; +const SEARCH_TOKEN_PATTERN = /\s+/; +const SAFE_SHELL_ARG_PATTERN = /^[A-Za-z0-9_./:@%+=,-]+$/; +const SUBAGENT_AGENT_IDS = new Set(["claude", "codex", "opencode", "pi"]); function isLoopbackBase(base: string): boolean { try { @@ -63,33 +103,280 @@ function useCopyButton(text: string) { }, 1600); }; - return { copied, copy }; + const reset = () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setCopied(false); + }; + + return { copied, copy, reset }; } -// Ids match the backend detection list; agents without an official `logo` asset get a monogram. -// Names are untranslated, so `settings.agents.intro` lists them all to keep them searchable. -const SUPPORTED_AGENTS: { +type AgentDetails = { id: string; name: string; + docsUrl: string; logo?: string; + icon?: string; + darkIcon?: string; + invertIconInDark?: boolean; color?: string; mark?: string; -}[] = [ - { id: "claude", name: "Claude Code", logo: "anthropic" }, - { id: "codex", name: "OpenAI Codex", logo: "openai" }, - { id: "hermes", name: "Hermes", color: "#8B5CF6", mark: "He" }, - { id: "openclaw", name: "OpenClaw", color: "#F59E0B", mark: "Ol" }, - { id: "opencode", name: "OpenCode", color: "#3B82F6", mark: "Oc" }, - { id: "pi", name: "Pi", color: "#EC4899", mark: "Pi" }, +}; + +type ParsedModel = { + repo: string; + variant: string | null; +}; + +// Names are untranslated, so `settings.agents.intro` lists them all to keep them searchable. +const SUPPORTED_AGENTS: AgentDetails[] = [ + { + id: "claude", + name: "Claude Code", + docsUrl: "https://unsloth.ai/docs/basics/claude-code", + logo: "anthropic", + }, + { + id: "codex", + name: "OpenAI Codex", + docsUrl: "https://unsloth.ai/docs/basics/codex", + logo: "openai", + }, + { + id: "hermes", + name: "Hermes Agent", + docsUrl: "https://unsloth.ai/docs/integrations/hermes-agent", + icon: "hermes.svg", + invertIconInDark: true, + }, + { + id: "openclaw", + name: "OpenClaw", + docsUrl: "https://unsloth.ai/docs/integrations/openclaw", + icon: "openclaw.svg", + }, + { + id: "opencode", + name: "OpenCode", + docsUrl: "https://unsloth.ai/docs/integrations/opencode", + icon: "opencode-light.svg", + darkIcon: "opencode-dark.svg", + }, + { + id: "pi", + name: "Pi Coding Agent", + docsUrl: DOCS_URL, + icon: "pi.svg", + }, ]; -/** Official brand logo when available, else a brand-colored monogram tile. */ +const FALLBACK_AGENT = SUPPORTED_AGENTS[0]; + +function detailsFor(agentId: string): AgentDetails { + return ( + SUPPORTED_AGENTS.find((agent) => agent.id === agentId) ?? { + id: agentId, + name: agentId, + docsUrl: DOCS_URL, + color: "#64748B", + mark: agentId.slice(0, 2), + } + ); +} + +function splitModelVariant(model: string): ParsedModel { + const value = model.trim(); + if ( + !value || + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("~") || + (value.length >= 2 && value[1] === ":") + ) { + return { repo: value, variant: null }; + } + + const separator = value.lastIndexOf(":"); + if (separator < 0) { + return { repo: value, variant: null }; + } + const repo = value.slice(0, separator); + const variant = value.slice(separator + 1); + if (!(repo && variant) || variant.includes("/")) { + return { repo: value, variant: null }; + } + return { repo, variant }; +} + +function looksLikePath(value: string): boolean { + return ( + value.includes("\\") || + value.startsWith("/") || + value.startsWith("~") || + value.startsWith("./") || + value.startsWith("../") || + (value.length >= 2 && value[1] === ":") || + value.split("/").length > 2 + ); +} + +function isHuggingFaceRepo(model: string): boolean { + return HUGGING_FACE_REPO_PATTERN.test(model); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return ""; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + const unitIndex = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** unitIndex; + return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; +} + +function discoverGgufModels( + items: BackendModelDetails[], + cachedRepos: string[], +): { + models: string[]; + variants: Record; +} { + const models = [EXAMPLE_MODEL_REPO]; + const variants: Record = {}; + // Hugging Face ids are case-insensitive, and the catalog and cache endpoints can + // disagree on spelling; two rows for one repo would leave the load id on only one. + const seen = new Set(models.map((model) => model.toLowerCase())); + const add = (model: string) => { + // Local entries arrive here as absolute paths, and a path is case-sensitive on + // Linux: folding those would collapse two distinct models into one. + const key = looksLikePath(model) ? model : model.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + models.push(model); + }; + for (const model of items) { + // /api/models/list reports the backend's raw identifier, which for a native + // grant is the host path that status deliberately withholds. The resident + // model reaches the picker through status instead, so drop path-shaped ids + // rather than leak one into the list and into the copied command. + if (!model.is_gguf || looksLikePath(model.id)) { + continue; + } + const parsed = splitModelVariant(model.id); + if (parsed.repo) { + add(parsed.repo); + } + if (parsed.variant && !variants[parsed.repo]) { + variants[parsed.repo] = parsed.variant; + } + } + for (const repo of cachedRepos) { + add(repo); + } + + return { models, variants }; +} + +// Scanned local GGUFs (./models, LM Studio, custom folders) that the caches above +// miss. The id is the load id, i.e. the on-disk path for anything outside the active +// cache, so label the row by repo id when there is one but keep the path to load by. +// model_format is only set by the scanners that compute it: _scan_hf_cache leaves it +// unset, so a custom scan folder holding an HF cache layout would vanish from the +// picker on an exclusive check. Treat unset as unknown and fall back to the name. +function isLocalGguf(model: LocalModelInfo): boolean { + // The scanners set this only for a directory holding a primary, non-mmproj GGUF + // and no other weights, so an unset format means "not GGUF", not "unknown". Do not + // guess from the name: a safetensors folder called Foo-GGUF would load the + // transformers backend and then fail the GGUF-only agents. + return (model.model_format ?? "").toLowerCase() === "gguf"; +} + +function localGgufEntries( + models: LocalModelInfo[], +): { id: string; label: string }[] { + const entries: { id: string; label: string }[] = []; + for (const model of models) { + // partial marks an interrupted sharded download: variant discovery would treat + // the shards it has as complete and build a command that fails on load. The + // cached repo row still offers it, and _repo_gguf_load_id withholds the path. + if (model.partial || !(model.id && isLocalGguf(model))) { + continue; + } + // The path is the identity: two scanned models can share a basename, and it is + // also what --model needs. The friendly name is display only. + entries.push({ + id: model.id, + label: model.model_id || model.display_name || model.id, + }); + } + return entries; +} + +// First candidate the repo actually offers: an explicit pick, then the remembered +// one, then the repo default. +function pickVariant( + available: Set, + candidates: (string | null | undefined)[], +): string | null { + for (const candidate of candidates) { + if (candidate && available.has(candidate)) { + return candidate; + } + } + return null; +} + +function activeGgufSelection( + status: InferenceStatusResponse | null, +): { model: string; variant: string | null; named: boolean } | null { + if (!status?.is_gguf) { + return null; + } + if (!status.model_identifier) { + // A native file grant withholds the host path, so this GGUF is resident but + // has no id to pass. Carry its label and attach with a bare command instead. + return status.active_model + ? { + model: status.active_model, + variant: status.gguf_variant ?? null, + named: false, + } + : null; + } + const active = splitModelVariant(status.model_identifier); + if (!active.repo) { + return null; + } + return { + // Status reports the quant for path loads too, whose id has no ":variant" suffix. + model: active.repo, + variant: status.gguf_variant ?? active.variant, + named: true, + }; +} + +/** Official provider or agent logo when available, else a monogram tile. */ function AgentIcon({ logo, + icon, + darkIcon, + invertIconInDark, color, mark, }: { logo?: string; + icon?: string; + darkIcon?: string; + invertIconInDark?: boolean; color?: string; mark?: string; }) { @@ -100,6 +387,34 @@ function AgentIcon({ ); } + if (icon) { + const iconSrc = `${import.meta.env.BASE_URL}agent-logos/${icon}`; + const darkIconSrc = darkIcon + ? `${import.meta.env.BASE_URL}agent-logos/${darkIcon}` + : null; + return ( + + + {darkIconSrc ? ( + + ) : null} + + ); + } return ( - - - {copied ? t("settings.agents.copied") : ""} - - - ); -} - // Flag tokens are literal; only the descriptions are localized. const OPTION_ROWS: { flag: string; descKey: TranslationKey }[] = [ { flag: "--model, -m", descKey: "settings.agents.options.model" }, @@ -169,20 +451,11 @@ const OPTION_ROWS: { flag: string; descKey: TranslationKey }[] = [ flag: "--persist / --no-persist", descKey: "settings.agents.options.persist", }, + { flag: "--as-subagent", descKey: "settings.agents.options.asSubagent" }, { flag: "--api-key", descKey: "settings.agents.options.apiKey" }, { flag: "--yolo", descKey: "settings.agents.options.yolo" }, ]; -const QUICKSTART_AGENT = "claude"; - -// Flags only: agentCommand supplies the prefix so every example targets the Studio -// this tab shows. Kept single line so the copy pastes as-is. -const MODEL_SUFFIX_FLAGS = - "--model unsloth/gemma-4-E2B-it-GGUF:UD-Q4_K_XL --context-length 32768"; - -const MODEL_VARIANT_FLAGS = - "--model unsloth/gemma-4-E2B-it-GGUF --gguf-variant UD-Q4_K_XL --context-length 32768"; - const REMOTE_CMD_UNIX = `export UNSLOTH_STUDIO_URL=https://studio.example.com export UNSLOTH_API_KEY=sk-unsloth-... unsloth start claude`; @@ -223,9 +496,113 @@ function CommandBlock({ command }: { command: string }) { strokeWidth={2} /> - + {copied ? t("settings.agents.copied") : ""} - + + + ); +} + +// Quote only values with shell metacharacters, e.g. a local path with spaces. +function quoteShellArg(value: string, windows: boolean): string { + if (SAFE_SHELL_ARG_PATTERN.test(value)) { + return value; + } + return windows ? `'${psSingle(value)}'` : `'${shSingle(value)}'`; +} + +function SubagentSection({ + agent, + baseCommand, + modelArgs, +}: { + agent: AgentDetails; + baseCommand: string; + modelArgs: string; +}) { + const t = useT(); + // modelArgs is empty when attaching to a resident model that has no id to name. + const command = `${baseCommand} --as-subagent${modelArgs ? ` ${modelArgs}` : ""}`; + const prompt = + agent.id === "opencode" + ? t("settings.agents.subagent.opencodePrompt") + : t("settings.agents.subagent.defaultPrompt"); + const commandCopy = useCopyButton(command); + const promptCopy = useCopyButton(prompt); + + if (!SUBAGENT_AGENT_IDS.has(agent.id)) { + return null; + } + + return ( +
+
+ + {t("settings.agents.subagent.title")} + +

+ {t("settings.agents.subagent.description", { agent: agent.name })} +

+
+ +
+
+ + {t("settings.agents.subagent.setupCommand")} + + +
+ + {command} + +
+ +
+
+ + {t("settings.agents.subagent.usagePrompt", { agent: agent.name })} + + +
+ + {prompt} + +
); } @@ -233,18 +610,142 @@ function CommandBlock({ command }: { command: string }) { export function AgentsTab() { const t = useT(); const serverUrl = usePlatformStore((s) => s.serverUrl); + const hfToken = useHfTokenStore((s) => s.token); const deviceType = usePlatformStore((s) => s.deviceType); - const [info, setInfo] = useState(null); - - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const localDetection = canUseLocalAgentDetection(serverUrl ?? origin); - // The remote snippet runs on the client, so use the client platform, not deviceType. // Anchor the match: a bare includes("win") would also match "darwin". const [isWindowsClient] = useState(() => { const p = getClientPlatform(); return p.startsWith("win") || p.includes("windows"); }); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + // Browser commands target the viewed origin; a desktop window origin is a Tauri URL + // the CLI cannot reach, so use the backend URL from /api/health (getApiBase until it + // lands). The command then runs wherever that CLI is: a loopback base is this Studio's + // own host, so deviceType decides, and it reports wsl where the browser would claim + // Windows; any other base is reached from the viewer's machine, so only the client + // platform describes that shell. + const studioBase = isTauri ? (serverUrl ?? getApiBase()) : origin; + const isWindowsShell = isLoopbackBase(studioBase) + ? deviceType === "windows" + : isWindowsClient; + const localDetection = canUseLocalAgentDetection(serverUrl ?? origin); + const [agents, setAgents] = useState( + SUPPORTED_AGENTS.map((agent) => agent.id), + ); + const [selectedAgent, setSelectedAgent] = useState(FALLBACK_AGENT.id); + const agentSelectionChanged = useRef(false); + const [detectedAgents, setDetectedAgents] = useState>(new Set()); + const [loaded, setLoaded] = useState(false); + const [models, setModels] = useState([EXAMPLE_MODEL_REPO]); + const [cachedLoadIds, setCachedLoadIds] = useState>( + {}, + ); + // Display names for scanned models, keyed by the path that identifies them. + const [modelLabels, setModelLabels] = useState>({}); + // The model /api/inference/status reports as resident, so the command attaches to it + // rather than remapping to another cached copy. + const [activeStatusModel, setActiveStatusModel] = useState( + null, + ); + // Set only for a native-grant GGUF, which is resident but has no id to pass. + const [attachOnlyModel, setAttachOnlyModel] = useState(null); + const [knownVariants, setKnownVariants] = useState>({ + [EXAMPLE_MODEL_REPO]: EXAMPLE_MODEL_VARIANT, + }); + const [selectedModel, setSelectedModel] = useState(EXAMPLE_MODEL_REPO); + const modelSelectionChanged = useRef(false); + // The model status last reported, for the discovery scan to preserve. + const activeModelRef = useRef(null); + // Only the newest status request may apply; a slow earlier one must not win. + const statusSeq = useRef(0); + // A quant picked by hand, scoped to its repo: polling and refetches must not + // overwrite it, but it must not follow the selection onto a different repo. + const chosenVariant = useRef<{ model: string; variant: string } | null>(null); + const [modelSearch, setModelSearch] = useState(""); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const [variants, setVariants] = useState([]); + const [defaultVariant, setDefaultVariant] = useState(null); + const [selectedVariant, setSelectedVariant] = useState( + EXAMPLE_MODEL_VARIANT, + ); + const [variantsLoading, setVariantsLoading] = useState(true); + const [variantsFailed, setVariantsFailed] = useState(false); + + const labelFor = (model: string) => modelLabels[model] ?? model; + const matchingModels = useMemo(() => { + const tokens = modelSearch + .trim() + .toLowerCase() + .split(SEARCH_TOKEN_PATTERN) + .filter(Boolean); + const matches = + tokens.length === 0 + ? models + : models.filter((model) => { + // Search both, so a scanned model is findable by name and by path. + const haystack = + `${model} ${modelLabels[model] ?? ""}`.toLowerCase(); + return tokens.every((token) => haystack.includes(token)); + }); + + if (tokens.length === 0 && matches.includes(selectedModel)) { + return [ + selectedModel, + ...matches.filter((model) => model !== selectedModel), + ]; + } + return matches; + }, [modelLabels, modelSearch, models, selectedModel]); + + const visibleModels = matchingModels.slice(0, MODEL_RESULT_LIMIT); + const preferredVariant = knownVariants[selectedModel] ?? null; + const selectedAgentDetails = detailsFor(selectedAgent); + // A GGUF outside the active cache does not resolve by repo id, so name its + // snapshot path; `unsloth start` now also matches a path by the basename + // /v1/models advertises for it. The resident model is exempt: it already + // loaded by id, and cached-gguf keeps the largest copy across caches, whose + // snapshot could switch cache or quant under it. + const cachedLoadId = + selectedModel === activeStatusModel + ? null + : (cachedLoadIds[selectedModel] ?? + cachedLoadIds[selectedModel.toLowerCase()] ?? + null); + const modelId = cachedLoadId ?? selectedModel; + const suffixVariant = isHuggingFaceRepo(modelId); + const commandModel = + selectedVariant && suffixVariant + ? `${modelId}:${selectedVariant}` + : modelId; + const commandModelArg = quoteShellArg(commandModel, isWindowsShell); + // A bare `unsloth start` attaches to whatever is loaded, which is the only way + // to reach a native-grant GGUF: naming it would switch the server to another model. + const attachOnly = selectedModel === attachOnlyModel; + const modelArgs = attachOnly + ? "" + : selectedVariant && !suffixVariant + ? `--model ${commandModelArg} --gguf-variant ${quoteShellArg(selectedVariant, isWindowsShell)}` + : `--model ${commandModelArg}`; + // No key is passed: the CLI caches an explicit one per base, overwriting a working + // saved key. Omitting it replays the saved key; the remote section covers first setup. + const commandOs = isWindowsShell ? "windows" : "unix"; + const commandBase = buildAgentCommand( + studioBase, + null, + commandOs, + selectedAgent, + ); + const command = attachOnly ? commandBase : `${commandBase} ${modelArgs}`; + // The fixed examples below target the same Studio, not a bare 127.0.0.1:8888. + const example = (agentId: string, flags: string) => + `${buildAgentCommand(studioBase, null, commandOs, agentId)} ${flags}`; + const { + copied, + copy: handleCopy, + reset: resetCopied, + } = useCopyButton(command); + const remoteCommand = isWindowsClient ? REMOTE_CMD_WINDOWS : REMOTE_CMD_UNIX; useEffect(() => { void fetchDeviceType({ force: true }); @@ -252,56 +753,324 @@ export function AgentsTab() { // A remote backend's PATH says nothing about the machine running the copied command. useEffect(() => { - if (!localDetection) return; + if (!localDetection) { + return; + } let cancelled = false; loadCodingAgents() .then((next) => { - if (!cancelled) setInfo(next); + if (cancelled) { + return; + } + if (next.agents.length > 0) { + setAgents(next.agents); + setSelectedAgent((current) => { + if (agentSelectionChanged.current) { + return current; + } + const detected = next.detected.find((agent) => + next.agents.includes(agent), + ); + return ( + detected ?? + (next.agents.includes(current) ? current : next.agents[0]) + ); + }); + } + setDetectedAgents(new Set(next.detected)); }) .catch(() => { // Best-effort; the tab still works without PATH detection. + }) + .finally(() => { + if (!cancelled) { + setLoaded(true); + } }); return () => { cancelled = true; }; }, [localDetection]); - // Derive visibility from localDetection instead of clearing info in the effect. - const visibleInfo = localDetection ? info : null; - const detected = new Set(visibleInfo?.detected ?? []); - const remoteCommand = isWindowsClient ? REMOTE_CMD_WINDOWS : REMOTE_CMD_UNIX; + useEffect(() => { + let cancelled = false; + Promise.all([ + listModels().catch(() => null), + listCachedGguf().catch(() => []), + listLocalModels().catch(() => null), + ]) + .then(([info, cachedGgufs, local]) => { + if (cancelled) { + return; + } + const localEntries = localGgufEntries(local?.models ?? []); + const discovered = discoverGgufModels(info?.models ?? [], [ + ...cachedGgufs.map((cached) => cached.repo_id), + ...localEntries.map((entry) => entry.id), + ]); + // Keep the snapshot load_id for --model while listing the model by repo id. + const loadIds: Record = {}; + for (const cached of cachedGgufs) { + if (cached.load_id && cached.load_id !== cached.repo_id) { + // Key both spellings: the merge above keeps whichever casing arrived + // first, which may not be this endpoint's. + loadIds[cached.repo_id] = cached.load_id; + loadIds[cached.repo_id.toLowerCase()] = cached.load_id; + } + } + const labels: Record = {}; + for (const entry of localEntries) { + if (entry.label !== entry.id) { + labels[entry.id] = entry.label; + } + } + // Status is applied on its own schedule now, so keep whatever model it has + // already adopted rather than dropping it when this slower scan lands. + setModels(() => { + const active = activeModelRef.current; + return active && !discovered.models.includes(active) + ? [active, ...discovered.models] + : discovered.models; + }); + setCachedLoadIds(loadIds); + setModelLabels(labels); + setKnownVariants((current) => ({ + ...current, + ...discovered.variants, + })); + }) + .catch(() => { + // The example model keeps the builder useful if discovery fails. + }); + return () => { + cancelled = true; + }; + }, []); - // `codex` needs a GGUF model (unsloth_cli's _require_gguf_for_codex exits otherwise), so flag - // its row instead of offering a failing command. Same three signals the API usage panel uses. - const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant); - const activeNativePathToken = useChatRuntimeStore( - (s) => s.activeNativePathToken, + // List the resident model and follow it, unless the user picked one explicitly. + const adoptActiveModel = useCallback( + (active: { model: string; variant: string | null }) => { + setModels((current) => + current.includes(active.model) ? current : [active.model, ...current], + ); + if (active.variant) { + setKnownVariants((current) => ({ + ...current, + [active.model]: active.variant as string, + })); + } + if (!modelSelectionChanged.current) { + setSelectedModel(active.model); + if (chosenVariant.current?.model !== active.model) { + setSelectedVariant(active.variant); + } + } + }, + [], ); - const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); - const isGguf = - activeGgufVariant != null || - activeNativePathToken != null || - ggufContextLength != null; - // Build from the reachable base: a bare `unsloth start` only probes 127.0.0.1:8888, but the - // desktop falls back across 8888-8908 and Studio may be remote. The browser must use its own - // origin, since /api/health reports the backend's localhost (the user's, behind a tunnel); - // the desktop has no window origin and falls back to getApiBase() while serverUrl loads. - // No --api-key: the CLI caches an explicit key per base, so a placeholder would overwrite a - // working saved one. Omitting it replays the saved key; the remote section covers first setup. - const commandBase = isTauri ? (serverUrl ?? getApiBase()) : origin; - // The command runs wherever the CLI is. For a loopback base that is this Studio's - // own host, so use deviceType, which reports wsl where the browser would claim - // Windows and emit $env: syntax bash rejects. A remote base is reached from the - // viewer's machine instead, so only the client platform describes that shell. - const commandOs = - (isLoopbackBase(commandBase) ? deviceType === "windows" : isWindowsClient) - ? "windows" - : "unix"; - const agentCommand = (agentId: string) => - buildAgentCommand(commandBase, null, commandOs, agentId); - const example = (agentId: string, flags: string) => - `${agentCommand(agentId)} ${flags}`; + // A native-grant label only stands for whatever was resident at the time, so once + // that model is replaced the label cannot name anything and has to go, even when + // it was picked by hand: leaving it selected would emit it as --model. + const retireAttachOnly = useCallback((label: string, replacement: string) => { + setModels((current) => current.filter((model) => model !== label)); + setSelectedModel((current) => { + if (current !== label) { + return current; + } + // Drop the quant in the same transition: it belonged to the label, and an + // explicit pick stops adoptActiveModel from correcting it afterwards. + chosenVariant.current = null; + setSelectedVariant(null); + return replacement; + }); + }, []); + + // The resident GGUF went away (unloaded, or replaced by a transformer model). + // Following it means letting go too, or the command would name a stale model and + // switch the shared server back. A native-grant label is not even loadable, so it + // leaves the list entirely. An explicit pick still wins. + const dropActiveModel = useCallback( + (attachOnly: string | null, wasActive: string | null) => { + if (attachOnly) { + setModels((current) => current.filter((model) => model !== attachOnly)); + // Even a deliberate pick has to go: the label stood for a withheld path, so + // naming it would emit --model