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>
This commit is contained in:
parent
1255964d5a
commit
7f0910fcc6
22 changed files with 1924 additions and 214 deletions
|
|
@ -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
|
||||
(``<root>/models--org--name/snapshots/<rev>``). 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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
studio/frontend/public/agent-logos/hermes.svg
Normal file
9
studio/frontend/public/agent-logos/hermes.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!-- Source: https://github.com/NousResearch/hermes-agent/blob/main/acp_registry/icon.svg -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
18
studio/frontend/public/agent-logos/openclaw.svg
Normal file
18
studio/frontend/public/agent-logos/openclaw.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!-- Source: https://github.com/openclaw/openclaw/blob/main/apps/linux/src-tauri/icons/icon.svg -->
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
<stop offset="100%" stop-color="#991b1b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
studio/frontend/public/agent-logos/opencode-dark.svg
Normal file
19
studio/frontend/public/agent-logos/opencode-dark.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Source: https://github.com/anomalyco/opencode/blob/dev/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg -->
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0)">
|
||||
<mask id="mask0" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 796 B |
19
studio/frontend/public/agent-logos/opencode-light.svg
Normal file
19
studio/frontend/public/agent-logos/opencode-light.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Source: https://github.com/anomalyco/opencode/blob/dev/packages/console/app/src/asset/brand/opencode-logo-light-square.svg -->
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0)">
|
||||
<mask id="mask0" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
21
studio/frontend/public/agent-logos/pi.svg
Normal file
21
studio/frontend/public/agent-logos/pi.svg
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!-- Source: https://pi.dev/favicon.svg (official Pi press-kit badge) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<rect width="800" height="800" rx="120" fill="#09090b"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="
|
||||
M165.29 165.29
|
||||
H517.36
|
||||
V400
|
||||
H400
|
||||
V517.36
|
||||
H282.65
|
||||
V634.72
|
||||
H165.29
|
||||
Z
|
||||
M282.65 282.65
|
||||
V400
|
||||
H400
|
||||
V282.65
|
||||
Z
|
||||
"/>
|
||||
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -141,8 +141,9 @@ const AGENT_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -104,13 +104,15 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -163,7 +163,8 @@ export const en = {
|
|||
},
|
||||
dictionary: {
|
||||
sectionTitle: "Dictation dictionary",
|
||||
sectionDescription: "Set how dictation spells specific words or phrases",
|
||||
sectionDescription:
|
||||
"Set how dictation spells specific words or phrases",
|
||||
manageLabel: "Custom spellings",
|
||||
manage: "Manage",
|
||||
backToVoice: "Back to Voice",
|
||||
|
|
@ -467,7 +468,8 @@ export const en = {
|
|||
"Unsupported file type. Use .woff2, .woff, .ttf, or .otf.",
|
||||
errorTooLarge: "Font file is too large (max 1.5 MB).",
|
||||
errorLimit: "You can import up to 3 fonts.",
|
||||
errorStorageFull: "Not enough local storage for this font. Remove an imported font first.",
|
||||
errorStorageFull:
|
||||
"Not enough local storage for this font. Remove an imported font first.",
|
||||
errorFailed: "Could not load this font file.",
|
||||
},
|
||||
uiFontSize: {
|
||||
|
|
@ -582,16 +584,47 @@ export const en = {
|
|||
},
|
||||
},
|
||||
agents: {
|
||||
title: "Agents (unsloth start)",
|
||||
title: "Agents",
|
||||
description:
|
||||
"Connect coding agents like Claude Code and Codex to a model running locally in Unsloth.",
|
||||
"Connect coding agents like Claude Code and Codex to a model running locally in Unsloth with unsloth start.",
|
||||
intro:
|
||||
"connects Claude Code, Codex, Hermes, OpenClaw, OpenCode, Pi and other agents to a model served locally by Unsloth, fully offline on your own hardware. It runs a OpenAI-compatible server for the agent and never touches your agent's config files.",
|
||||
"connects Claude Code, Codex, Hermes, OpenClaw, OpenCode, Pi and other agents to a model served locally by Unsloth, fully offline on your own hardware. It runs an OpenAI-compatible server for the agent and never touches your agent's config files.",
|
||||
readDocs: "Read the docs",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
commandBuilder: "Command builder",
|
||||
agent: "Coding agent",
|
||||
model: "Model",
|
||||
searchModels: "Search GGUF models...",
|
||||
noModels: "No matching GGUF models.",
|
||||
showingModels:
|
||||
"Showing {shown} of {total} matches. Keep typing to narrow the list.",
|
||||
quantization: "Quantization",
|
||||
loadingQuantizations: "Loading quantizations...",
|
||||
noQuantizations: "No separate quantization",
|
||||
recommended: "Recommended",
|
||||
downloaded: "Downloaded",
|
||||
quantizationLoadError:
|
||||
"Couldn't load all quantizations. The command will use the available model value.",
|
||||
generatedCommand: "Generated command",
|
||||
docs: "Docs",
|
||||
agentDocs: "Open {agent} setup docs",
|
||||
copyGeneratedCommand: "Copy generated command",
|
||||
modelNote:
|
||||
"Codex requires a GGUF model served by llama-server. Other agents can also use transformer-backed models; remove --model to use the model already loaded in Unsloth Studio.",
|
||||
subagent: {
|
||||
title: "Use a local model as a subagent",
|
||||
description:
|
||||
"Keep {agent} on its current model and delegate selected tasks to this local Unsloth model.",
|
||||
setupCommand: "Setup command",
|
||||
copySetupCommand: "Copy subagent setup command",
|
||||
usagePrompt: "Then in {agent}, type:",
|
||||
copyUsagePrompt: "Copy subagent usage prompt",
|
||||
defaultPrompt: "Spawn a local agent to implement this function.",
|
||||
opencodePrompt: "@unsloth find the cause of this test failure",
|
||||
},
|
||||
quickstart: {
|
||||
title: "Quickstart",
|
||||
title: "Build a command",
|
||||
description:
|
||||
"Launch an agent against the model currently loaded in Studio. Load a model first, then swap claude for any supported agent below.",
|
||||
noneDetected: "No supported agent CLIs were found on your PATH.",
|
||||
|
|
@ -623,6 +656,8 @@ export const en = {
|
|||
serve: "Enable or disable the automatic local server.",
|
||||
launch: "Launch the agent, or just print the command and environment.",
|
||||
persist: "Keep Unsloth-managed agent storage between runs.",
|
||||
asSubagent:
|
||||
"Keep the parent on its current model and register Unsloth as a local subagent (Claude Code, Codex, OpenCode, and Pi).",
|
||||
apiKey: "Provide your Unsloth API key (or set UNSLOTH_API_KEY).",
|
||||
yolo: "Skip approval prompts. Use only in trusted environments.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -559,11 +559,18 @@ def _subagent_model_id(
|
|||
)
|
||||
if status.get("is_gguf"):
|
||||
variant = status.get("gguf_variant")
|
||||
return (
|
||||
_display_model_spec(model_id, str(variant))
|
||||
if variant and _is_hub_model_id(model_id)
|
||||
else model_id
|
||||
)
|
||||
if variant and _is_hub_model_id(model_id):
|
||||
return _display_model_spec(model_id, str(variant))
|
||||
if variant:
|
||||
# A path load is advertised as a bare basename with no ":variant" channel,
|
||||
# so the quant cannot be recorded and a later reload picks for itself.
|
||||
typer.echo(
|
||||
f"Warning: {model_id} loaded from a path, so the subagent config cannot "
|
||||
f"pin the {variant} quant; a reload may choose a different one. Load the "
|
||||
"model by repository id to pin it.",
|
||||
err = True,
|
||||
)
|
||||
return model_id
|
||||
|
||||
|
||||
def _fail(message: str) -> NoReturn:
|
||||
|
|
@ -572,9 +579,8 @@ def _fail(message: str) -> NoReturn:
|
|||
|
||||
|
||||
def _reject_as_subagent(agent: str, args: list) -> None:
|
||||
# Reject early; otherwise the flag reaches the agent binary and fails after
|
||||
# Studio has already loaded the model.
|
||||
if "--as-subagent" in args:
|
||||
# Reject early, or the flag reaches the agent binary after Studio loaded the model.
|
||||
if any(arg == "--as-subagent" or arg.startswith("--as-subagent=") for arg in args):
|
||||
_fail(f"--as-subagent is not supported for {agent}.")
|
||||
|
||||
|
||||
|
|
@ -1386,6 +1392,37 @@ def _is_hub_model_id(value: object) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _is_model_path(value: str) -> bool:
|
||||
"""Mirrors core.inference.model_ids._looks_like_path: a repo id is exactly
|
||||
``org/model``; anything else with a separator, drive, prefix or .gguf is a path.
|
||||
|
||||
Deliberately not named _looks_like_path: that name is taken further down by the
|
||||
WSLENV classifier, which only matches absolute paths and would shadow this one.
|
||||
"""
|
||||
if value.lower().endswith(".gguf"):
|
||||
return True
|
||||
if value.startswith(("/", "\\", "./", "../", ".\\", "..\\", "~")):
|
||||
return True
|
||||
if len(value) >= 2 and value[1] == ":":
|
||||
return True
|
||||
return value.count("/") >= 2 or "\\" in value
|
||||
|
||||
|
||||
def _public_model_id(value: Optional[str]) -> Optional[str]:
|
||||
"""The id Unsloth advertises for a model loaded by path.
|
||||
|
||||
/v1/models never echoes a host path: it reports the file or directory name
|
||||
with any .gguf suffix stripped (core.inference.model_ids.public_model_id), so
|
||||
a path we asked to load has to be matched by that name too.
|
||||
"""
|
||||
if not value or not _is_model_path(value):
|
||||
return None
|
||||
name = os.path.basename(value.replace("\\", "/").rstrip("/"))
|
||||
if name.lower().endswith(".gguf"):
|
||||
name = name[: -len(".gguf")]
|
||||
return name or None
|
||||
|
||||
|
||||
def _model_id_matches(
|
||||
actual: object,
|
||||
requested: object,
|
||||
|
|
@ -1486,7 +1523,7 @@ def _resolve_model(
|
|||
# casing) that /v1/models echoes but which may differ from the path we
|
||||
# passed; match on the id the load reports so we don't silently fall
|
||||
# through to models[0] and connect to a different loaded model.
|
||||
wanted = {requested}
|
||||
wanted = {requested, _public_model_id(requested)} - {None}
|
||||
if isinstance(loaded, dict):
|
||||
wanted |= {loaded.get("model"), loaded.get("display_name")} - {None}
|
||||
models = _loaded_models(base, key)
|
||||
|
|
@ -1954,7 +1991,15 @@ def _opencode_subagent_inline_config(path: Path, permission: dict) -> dict:
|
|||
def merge_provider_filters(effective_config: dict) -> None:
|
||||
enabled = effective_config.get("enabled_providers")
|
||||
if isinstance(enabled, list):
|
||||
inline["enabled_providers"] = list(dict.fromkeys([*enabled, _OPENCODE_PROVIDER]))
|
||||
inherited_enabled = inline.get("enabled_providers")
|
||||
if not isinstance(inherited_enabled, list):
|
||||
inherited_enabled = []
|
||||
providers = [
|
||||
provider
|
||||
for provider in [*inherited_enabled, *enabled]
|
||||
if provider != _OPENCODE_PROVIDER
|
||||
]
|
||||
inline["enabled_providers"] = list(dict.fromkeys([*providers, _OPENCODE_PROVIDER]))
|
||||
disabled = effective_config.get("disabled_providers")
|
||||
if isinstance(disabled, list) and _OPENCODE_PROVIDER in disabled:
|
||||
inline["disabled_providers"] = [
|
||||
|
|
@ -2871,7 +2916,13 @@ def write_pi_config(base: str, key: str, model: dict, path: Path) -> None:
|
|||
typer.echo(f"Updated {path}")
|
||||
|
||||
|
||||
def write_pi_subagent_config(base: str, key: str, model: dict, path: Path) -> None:
|
||||
def write_pi_subagent_config(
|
||||
base: str,
|
||||
key: str,
|
||||
model: dict,
|
||||
path: Path,
|
||||
approve: bool = False,
|
||||
) -> None:
|
||||
"""Write private bootstrap data for the bundled Pi extension."""
|
||||
window = model.get("context_length") or model.get("max_context_length")
|
||||
window = int(window) if window else 32768
|
||||
|
|
@ -2883,6 +2934,7 @@ def write_pi_subagent_config(base: str, key: str, model: dict, path: Path) -> No
|
|||
"model": model["id"],
|
||||
"contextWindow": window,
|
||||
"maxTokens": min(window // 4, 8192),
|
||||
"approve": approve,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -3452,7 +3504,13 @@ def pi(
|
|||
extension = _agent_config_path(_PI_SUBAGENT_EXTENSION, ["pi"])
|
||||
with _session_config("pi-subagent", launch, persist = persist) as config:
|
||||
config_path = config / "subagent.json"
|
||||
write_pi_subagent_config(base, key, subagent_model, config_path)
|
||||
write_pi_subagent_config(
|
||||
base,
|
||||
key,
|
||||
subagent_model,
|
||||
config_path,
|
||||
approve = yolo,
|
||||
)
|
||||
command = [
|
||||
"pi",
|
||||
"--extension",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
|
|||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
const provider = "unsloth";
|
||||
// Distinct from the normal `unsloth` provider: subagent mode preserves the user's Pi config.
|
||||
const provider = "unsloth-studio-subagent";
|
||||
const maxResultCharacters = 100_000;
|
||||
const maxParallelAgents = 4;
|
||||
const cancelGraceMilliseconds = 2_000;
|
||||
|
|
@ -26,6 +27,7 @@ if (configPath) {
|
|||
const model = typeof config.model === "string" ? config.model : "";
|
||||
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
|
||||
const apiKey = typeof config.apiKey === "string" ? config.apiKey : "";
|
||||
const approve = config.approve === true;
|
||||
const contextWindow = positiveInt(config.contextWindow, 32768);
|
||||
const maxTokens = positiveInt(config.maxTokens, Math.min(Math.floor(contextWindow / 4), 8192));
|
||||
let activeAgents = 0;
|
||||
|
|
@ -168,6 +170,7 @@ async function runLocalAgent(
|
|||
"json",
|
||||
"--print",
|
||||
"--no-session",
|
||||
...(approve ? ["--approve"] : []),
|
||||
"--provider",
|
||||
provider,
|
||||
"--model",
|
||||
|
|
|
|||
|
|
@ -885,8 +885,9 @@ def test_subagent_model_id_warns_when_status_unavailable(monkeypatch, capsys):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("agent", ["openclaw", "hermes"])
|
||||
def test_unsupported_agents_reject_as_subagent(agent):
|
||||
result = CliRunner().invoke(start.start_app, [agent, "--as-subagent"])
|
||||
@pytest.mark.parametrize("flag", ["--as-subagent", "--as-subagent=true", "--as-subagent=false"])
|
||||
def test_unsupported_agents_reject_as_subagent(agent, flag):
|
||||
result = CliRunner().invoke(start.start_app, [agent, flag])
|
||||
assert result.exit_code == 1
|
||||
assert f"--as-subagent is not supported for {agent}." in result.output
|
||||
|
||||
|
|
@ -1296,6 +1297,66 @@ def test_resolve_model_matches_loaded_canonical_case_after_load(monkeypatch, cap
|
|||
assert "please wait" not in output
|
||||
|
||||
|
||||
def test_resolve_model_matches_snapshot_path_by_public_id(monkeypatch):
|
||||
"""A GGUF loaded by snapshot path is advertised by its basename, not the path."""
|
||||
snapshot = "/home/u/.cache/legacy/models--Org--Model/snapshots/abc123"
|
||||
state = {"loaded": False}
|
||||
|
||||
def http_json(
|
||||
method,
|
||||
url,
|
||||
token,
|
||||
payload = None,
|
||||
timeout = 30,
|
||||
error = None,
|
||||
):
|
||||
if url.endswith("/v1/models"):
|
||||
return {"data": [{"id": "abc123"}] if state["loaded"] else []}
|
||||
if url.endswith("/api/inference/load"):
|
||||
state["loaded"] = True
|
||||
# The load echoes the path it was given, which /v1/models never lists.
|
||||
return {"model": snapshot, "display_name": snapshot}
|
||||
raise AssertionError(f"unexpected request: {method} {url}")
|
||||
|
||||
monkeypatch.setattr(start, "_http_json", http_json)
|
||||
|
||||
entry = start._resolve_model(BASE, "sk-test", snapshot, start.LoadOptions())
|
||||
|
||||
assert entry["id"] == "abc123"
|
||||
|
||||
|
||||
def test_subagent_model_id_warns_when_a_path_load_cannot_pin_the_quant(capsys):
|
||||
"""A path is advertised as a bare basename, so the quant cannot be recorded."""
|
||||
model_id = start._subagent_model_id(BASE, "sk-test", {"id": "abc123"}, None, "UD-Q4_K_XL")
|
||||
|
||||
assert model_id == "abc123"
|
||||
assert "cannot pin the UD-Q4_K_XL quant" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_subagent_model_id_pins_the_quant_for_repo_ids(capsys):
|
||||
model_id = start._subagent_model_id(
|
||||
BASE, "sk-test", {"id": "unsloth/gemma-4-E4B-it-GGUF"}, None, "UD-Q4_K_XL"
|
||||
)
|
||||
|
||||
assert model_id == "unsloth/gemma-4-E4B-it-GGUF:UD-Q4_K_XL"
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_public_model_id_leaves_repo_ids_alone():
|
||||
"""Only a path gets reduced; a repo id must not match some unrelated model.
|
||||
|
||||
Relative and multi-segment paths are covered too: _looks_like_path is defined
|
||||
twice in this module (the WSLENV one wins), so this must use its own classifier.
|
||||
"""
|
||||
assert start._public_model_id("unsloth/gemma-4-E4B-it-GGUF") is None
|
||||
assert start._public_model_id("org/model") is None
|
||||
assert start._public_model_id("/srv/models/Qwen3-Q4_K_M.gguf") == "Qwen3-Q4_K_M"
|
||||
assert start._public_model_id("/a/b/snapshots/rev1") == "rev1"
|
||||
assert start._public_model_id("./models/foo") == "foo"
|
||||
assert start._public_model_id("cache/snapshots/rev") == "rev"
|
||||
assert start._public_model_id("a/b/c") == "c"
|
||||
|
||||
|
||||
def test_resolve_model_loads_when_catalog_hit_is_not_loaded(monkeypatch):
|
||||
# A cached-but-unloaded catalog entry (loaded == False) that only case-differs must
|
||||
# not be treated as ready; the load endpoint must still be called so the requested
|
||||
|
|
@ -3296,9 +3357,13 @@ def test_write_opencode_config_as_subagent_preserves_parent_model(tmp_path):
|
|||
|
||||
def test_opencode_subagent_inline_keeps_parent_provider_filters(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "opencode.json"
|
||||
inherited = {"theme": "tokyonight"}
|
||||
inherited = {
|
||||
"theme": "tokyonight",
|
||||
"enabled_providers": ["anthropic"],
|
||||
}
|
||||
monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", json.dumps(inherited))
|
||||
monkeypatch.setattr(start, "_which_with_install_dirs", lambda _: "/usr/bin/opencode")
|
||||
monkeypatch.setattr(start, "_wsl_windows_executable", lambda _: None)
|
||||
captured = {}
|
||||
|
||||
def run(command, **kwargs):
|
||||
|
|
@ -3324,7 +3389,11 @@ def test_opencode_subagent_inline_keeps_parent_provider_filters(monkeypatch, tmp
|
|||
assert captured["env"]["OPENCODE_CONFIG"] == str(config_path)
|
||||
assert inline == {
|
||||
"theme": "tokyonight",
|
||||
"enabled_providers": ["opencode-go", start._OPENCODE_PROVIDER],
|
||||
"enabled_providers": [
|
||||
"anthropic",
|
||||
"opencode-go",
|
||||
start._OPENCODE_PROVIDER,
|
||||
],
|
||||
"disabled_providers": ["ollama"],
|
||||
"subagent_depth": 1,
|
||||
"permission": permission,
|
||||
|
|
@ -3721,21 +3790,26 @@ def test_connect_pi_no_launch(fake_studio, tmp_path):
|
|||
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
|
||||
|
||||
|
||||
def test_connect_pi_as_subagent_preserves_cloud_parent(fake_studio, tmp_path):
|
||||
@pytest.mark.parametrize("yolo", [False, True])
|
||||
def test_connect_pi_as_subagent_preserves_cloud_parent(fake_studio, tmp_path, yolo):
|
||||
args = [
|
||||
"pi",
|
||||
"--as-subagent",
|
||||
"--no-launch",
|
||||
"--model",
|
||||
MODEL["id"] + ":UD-Q4_K_XL",
|
||||
]
|
||||
if yolo:
|
||||
args.insert(2, "--yolo")
|
||||
result = CliRunner().invoke(
|
||||
start.start_app,
|
||||
[
|
||||
"pi",
|
||||
"--as-subagent",
|
||||
"--no-launch",
|
||||
"--model",
|
||||
MODEL["id"] + ":UD-Q4_K_XL",
|
||||
],
|
||||
args,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
command = _launch_command(result.output)
|
||||
assert command[:2] == ["pi", "--extension"]
|
||||
assert command[2].endswith("unsloth_cli/pi_subagent.ts")
|
||||
assert ("--approve" in command) is yolo
|
||||
assert "--provider" not in command
|
||||
assert "--model" not in command
|
||||
assert "PI_CODING_AGENT_DIR" not in result.output
|
||||
|
|
@ -3750,6 +3824,7 @@ def test_connect_pi_as_subagent_preserves_cloud_parent(fake_studio, tmp_path):
|
|||
"model": MODEL["id"] + ":UD-Q4_K_XL",
|
||||
"contextWindow": 4096,
|
||||
"maxTokens": 1024,
|
||||
"approve": yolo,
|
||||
}
|
||||
assert "Ask Pi to spawn an Unsloth or local agent." in result.output
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue