unsloth/studio/backend/tests/test_local_model_format.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

179 lines
6.5 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
"""Tests for local GGUF ``model_format`` classification (PR #6364 follow-up).
Suffixless GGUF folders (custom folders / LM Studio) carry no ``-GGUF`` name
hint, so the scanners must surface ``model_format = "gguf"`` for the UI to route
them through the GGUF load path. The rule, shared by ``_dir_model_format`` and
``_scan_models_dir``: a directory is GGUF-format when it holds ``.gguf`` files
and no non-GGUF weights (``.safetensors`` / ``.bin``); a stray ``config.json``
must not disqualify it.
No GPU/network: only file names and sizes are inspected.
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
# Keep runnable without optional logging deps (mirrors the sibling tests).
if "structlog" not in sys.modules:
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
sys.modules["structlog"] = types.SimpleNamespace(
BoundLogger = _DummyLogger,
get_logger = lambda *args, **kwargs: _DummyLogger(),
)
import routes.models as models_route
def _touch(path: Path) -> Path:
path.parent.mkdir(parents = True, exist_ok = True)
path.write_bytes(b"\0")
return path
def test_dir_model_format_gguf_only(tmp_path):
d = tmp_path / "model"
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) == "gguf"
def test_dir_model_format_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"
_touch(d / "config.json")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) == "gguf"
def test_dir_model_format_mixed_weights_is_not_gguf(tmp_path):
# Real safetensors weights present -> not a GGUF folder.
d = tmp_path / "model"
_touch(d / "model.safetensors")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) is None
def test_dir_model_format_no_gguf(tmp_path):
d = tmp_path / "model"
_touch(d / "config.json")
_touch(d / "model.safetensors")
assert models_route._dir_model_format(d) is None
def test_dir_model_format_ignores_tokenizer_bin(tmp_path):
# A companion tokenizer.bin is not a weight file, so a GGUF folder shipping
# one is still GGUF (not misread as a plain .bin checkpoint).
d = tmp_path / "model"
_touch(d / "tokenizer.bin")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) == "gguf"
def test_dir_model_format_weight_bin_is_not_gguf(tmp_path):
# A real PyTorch weight .bin alongside a .gguf means mixed weights -> None.
d = tmp_path / "model"
_touch(d / "pytorch_model.bin")
_touch(d / "model-Q4_K_M.gguf")
assert models_route._dir_model_format(d) is None
def test_scan_models_dir_classifies_gguf_with_config(tmp_path):
root = tmp_path / "models"
# GGUF repo that also ships a config.json (the regression case).
_touch(root / "gguf_repo" / "config.json")
_touch(root / "gguf_repo" / "model-Q4_K_M.gguf")
# A plain safetensors checkpoint stays non-GGUF.
_touch(root / "st_repo" / "config.json")
_touch(root / "st_repo" / "model.safetensors")
# A standalone .gguf file is GGUF.
_touch(root / "loose.gguf")
fmt = {Path(m.path).name: m.model_format for m in models_route._scan_models_dir(root)}
assert fmt["gguf_repo"] == "gguf"
assert fmt["st_repo"] is None
assert fmt["loose.gguf"] == "gguf"
def test_scan_models_dir_classifies_root_gguf_with_config(tmp_path):
# Custom scan folders can point directly at a GGUF repo, not only at a
# parent directory that contains model repos.
root = tmp_path / "SuffixlessRepo"
_touch(root / "config.json")
_touch(root / "model-Q4_K_M.gguf")
[row] = models_route._scan_models_dir(root)
assert row.path == str(root)
assert row.model_format == "gguf"