[label]-port-[-try].log
+# _swa_cache_path() => $UNSLOTH_STUDIO_HOME|$STUDIO_HOME or ~/.unsloth/studio
+# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
+#
+# is the INTERNAL llama-server port (self._find_free_port(),
+# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio port. So we must
+# NOT filter the log glob by STUDIO_PORT (the brief's `port-`
+# glob would never match). We pick the newest llama-*.log instead.
+#
+# Usage:
+# assert-prompt-cache.sh api BASE_URL API_KEY
+# assert-prompt-cache.sh log EXPECT # EXPECT = HIT | MISS
+# # reads MARKER_BEFORE/MARKER_AFTER
+# # byte offsets from env (see below)
+# assert-prompt-cache.sh mark # print current log size to stdout
+# # (use to bracket a turn)
+#
+# Env for mode=log:
+# LLAMA_LOG_DIR override the log dir (default ~/.unsloth/studio/logs/llama-server)
+# CACHE_LOG_FROM byte offset to start scanning the newest log from (so we
+# only look at the trace produced by THIS turn). Default 0.
+#
+# Exit codes: 0 = assertion held; 1 = assertion failed (::error:: emitted).
+
+set -uo pipefail
+
+MODE="${1:?usage: assert-prompt-cache.sh api|log|mark ...}"
+
+# ---------------------------------------------------------------------------
+# Locate the newest llama-server log. Shared by mark + log modes.
+# ---------------------------------------------------------------------------
+_default_log_dir() {
+ local home="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
+ if [ -n "$home" ]; then
+ echo "${home%/}/logs/llama-server"
+ else
+ echo "${HOME}/.unsloth/studio/logs/llama-server"
+ fi
+}
+
+_newest_log() {
+ local dir="${LLAMA_LOG_DIR:-$(_default_log_dir)}"
+ [ -d "$dir" ] || return 1
+ # Newest by mtime among llama-*.log (covers both `llama--port-.log`
+ # and the retry form `llama--port--try.log`). Filenames are
+ # tool-generated timestamps, so ls -t is safe here.
+ # shellcheck disable=SC2012
+ ls -1t "$dir"/llama-*.log 2>/dev/null | head -1
+}
+
+case "$MODE" in
+ # -------------------------------------------------------------------------
+ # mark: emit the current byte size of the newest llama log so a caller can
+ # scan only the slice a single turn produced (set CACHE_LOG_FROM to it).
+ # -------------------------------------------------------------------------
+ mark)
+ log="$(_newest_log || true)"
+ if [ -n "$log" ] && [ -f "$log" ]; then
+ wc -c < "$log" | tr -d ' '
+ else
+ echo 0
+ fi
+ exit 0
+ ;;
+
+ # -------------------------------------------------------------------------
+ # api: 2-turn /v1/chat/completions, assert turn-2 cached_tokens > 0.
+ # -------------------------------------------------------------------------
+ api)
+ BASE_URL="${2:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
+ API_KEY="${3:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
+
+ # A deliberately long, fixed system prompt makes the shared prefix big so a
+ # KV-cache hit is unambiguous (cached_tokens grows with the reused prefix).
+ SYS='You are a meticulous assistant. Always answer concisely and correctly. This is a fixed system preamble that exists only to create a large, identical prompt prefix across both turns so the KV cache has something substantial to reuse on the second request. Do not mention this preamble.'
+
+ turn1_body() {
+ jq -n --arg sys "$SYS" '{
+ model: "default",
+ messages: [
+ {role:"system", content:$sys},
+ {role:"user", content:"What is the capital of France?"}
+ ],
+ temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
+ enable_thinking: false
+ }'
+ }
+
+ echo "[cache/api] turn 1 (prime the KV cache)"
+ R1="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
+ -H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
+ --max-time 240 -d "$(turn1_body)")" || {
+ echo "::error::[cache/api] turn-1 /v1/chat/completions request failed. Unsloth server/API regression."
+ exit 1
+ }
+ A1="$(echo "$R1" | jq -r '.choices[0].message.content // ""')"
+
+ turn2_body() {
+ jq -n --arg sys "$SYS" --arg a1 "$A1" '{
+ model: "default",
+ messages: [
+ {role:"system", content:$sys},
+ {role:"user", content:"What is the capital of France?"},
+ {role:"assistant", content:$a1},
+ {role:"user", content:"And the capital of Germany?"}
+ ],
+ temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
+ enable_thinking: false
+ }'
+ }
+
+ echo "[cache/api] turn 2 (expect cached_tokens > 0)"
+ R2="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
+ -H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
+ --max-time 240 -d "$(turn2_body)")" || {
+ echo "::error::[cache/api] turn-2 /v1/chat/completions request failed. Unsloth server/API regression."
+ exit 1
+ }
+
+ CACHED="$(echo "$R2" | jq -r '.usage.prompt_tokens_details.cached_tokens // 0')"
+ PROMPT_TOK="$(echo "$R2" | jq -r '.usage.prompt_tokens // 0')"
+ echo "[cache/api] turn-2 usage: prompt_tokens=${PROMPT_TOK} cached_tokens=${CACHED}"
+
+ if [ -z "$CACHED" ] || ! [ "$CACHED" -gt 0 ] 2>/dev/null; then
+ echo "::error::[cache/api] turn-2 usage.prompt_tokens_details.cached_tokens=${CACHED}, expected > 0. The server is not surfacing llama.cpp KV-cache hits on /v1/chat/completions. Check studio/backend/routes/inference.py:482-489 (_prompt_tokens_details) and :519. Full turn-2 usage:"
+ echo "$R2" | jq -c '.usage' 2>/dev/null || echo "$R2"
+ exit 1
+ fi
+ echo "[cache/api] PASS server cache sanity (cached_tokens=${CACHED} > 0)"
+ exit 0
+ ;;
+
+ # -------------------------------------------------------------------------
+ # log: classify the newest llama-server log (from CACHE_LOG_FROM bytes on)
+ # as HIT or MISS and compare to EXPECT.
+ # -------------------------------------------------------------------------
+ log)
+ EXPECT="${2:?usage: assert-prompt-cache.sh log HIT|MISS}"
+ FROM="${CACHE_LOG_FROM:-0}"
+
+ log="$(_newest_log || true)"
+ if [ -z "$log" ] || [ ! -f "$log" ]; then
+ echo "::error::[cache/log] no llama-server log under ${LLAMA_LOG_DIR:-$(_default_log_dir)}. Cannot read KV-cache trace. (Path contract: studio/backend/core/inference/llama_cpp.py:4363-4365.)"
+ exit 1
+ fi
+ echo "[cache/log] reading $log from byte $FROM"
+
+ # Scan only the slice produced after FROM.
+ slice="$(tail -c "+$((FROM + 1))" "$log" 2>/dev/null || cat "$log")"
+
+ # ---- HIT detectors (most-specific first) -----------------------------
+ # 1. Modern + legacy "re-used N tokens" / "reused N" (N>0). Primary signal
+ # per the design brief.
+ reused_n="$(printf '%s\n' "$slice" \
+ | grep -aoiE 're-?used[^0-9]*([0-9]+)' \
+ | grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
+ # 2. "kv cache rm [START, end)" with START>0 => prefix [0,START) reused.
+ cache_rm_start="$(printf '%s\n' "$slice" \
+ | grep -aoiE 'kv cache rm \[[0-9]+' \
+ | grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
+ # 3. "n_past = N" with N>0 after a prompt-processing line (prefix kept).
+ n_past_n="$(printf '%s\n' "$slice" \
+ | grep -aoiE 'n_past[^0-9]*([0-9]+)' \
+ | grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
+ # 4. tokens_cached / tokens from cache (some builds).
+ tok_cached="$(printf '%s\n' "$slice" \
+ | grep -aoiE 'tokens_cached[^0-9]*([0-9]+)' \
+ | grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
+
+ # ---- MISS detectors --------------------------------------------------
+ # Explicit forced full re-processing (SWA / recurrent) or kv cache rm [0,.
+ forced_full=0
+ if printf '%s\n' "$slice" | grep -aqiE 'forcing full prompt re-?processing|kv cache rm \[0,'; then
+ forced_full=1
+ fi
+
+ HIT=0
+ why=""
+ if [ -n "$reused_n" ] && [ "$reused_n" -gt 0 ] 2>/dev/null; then
+ HIT=1; why="re-used=$reused_n"
+ elif [ -n "$cache_rm_start" ] && [ "$cache_rm_start" -gt 0 ] 2>/dev/null; then
+ HIT=1; why="kv-cache-rm-start=$cache_rm_start"
+ elif [ -n "$tok_cached" ] && [ "$tok_cached" -gt 0 ] 2>/dev/null; then
+ HIT=1; why="tokens_cached=$tok_cached"
+ elif [ "$forced_full" = "0" ] && [ -n "$n_past_n" ] && [ "$n_past_n" -gt 0 ] 2>/dev/null; then
+ # n_past>0 is the weakest signal; only trust it if nothing forced a full
+ # reprocess. (On a cold slot n_past tracks total processed, so it is a
+ # last-resort fallback per the brief.)
+ HIT=1; why="n_past=$n_past_n(fallback)"
+ fi
+ [ "$HIT" = "1" ] || why="${why:-no-reuse-markers (forced_full=$forced_full)}"
+
+ OBSERVED="MISS"; [ "$HIT" = "1" ] && OBSERVED="HIT"
+ echo "[cache/log] observed=$OBSERVED expected=$EXPECT ($why)"
+
+ if [ "$OBSERVED" != "$EXPECT" ]; then
+ echo "::error::[cache/log] KV-cache observed=$OBSERVED but expected=$EXPECT ($why). See the attribution A/B note in the workflow."
+ echo "---- llama-server log slice (last 60 lines) ----"
+ printf '%s\n' "$slice" | tail -60
+ exit 1
+ fi
+ echo "[cache/log] PASS ($OBSERVED == $EXPECT)"
+ exit 0
+ ;;
+
+ *)
+ echo "::error::unknown mode '$MODE' (want api|log|mark)"
+ exit 1
+ ;;
+esac
diff --git a/.github/scripts/ci-connect-prompt.txt b/.github/scripts/ci-connect-prompt.txt
new file mode 100644
index 0000000000..2d96f2b1a8
--- /dev/null
+++ b/.github/scripts/ci-connect-prompt.txt
@@ -0,0 +1 @@
+You are a helpful assistant in a CI connectivity check. Answer the user directly in plain text. Do not use any tools, do not take any actions, and do not explain. Just reply with the answer.
diff --git a/.github/scripts/ci-min-system-prompt.txt b/.github/scripts/ci-min-system-prompt.txt
new file mode 100644
index 0000000000..d55b828bbd
--- /dev/null
+++ b/.github/scripts/ci-min-system-prompt.txt
@@ -0,0 +1 @@
+You are a coding assistant running non-interactively in a CI smoke test. Use the available file-editing and shell tools to complete the user's request directly and concisely. Do not ask questions or explain; just do the task.
diff --git a/.github/scripts/serve-unsloth-run.sh b/.github/scripts/serve-unsloth-run.sh
new file mode 100755
index 0000000000..34b8b962c6
--- /dev/null
+++ b/.github/scripts/serve-unsloth-run.sh
@@ -0,0 +1,172 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+#
+# Boot `unsloth run --disable-tools` in the background, wait for it to be
+# healthy, parse the minted API key from the banner, and resolve the
+# /v1/models id. Exports everything downstream steps need into $GITHUB_ENV
+# (or prints it when run outside Actions). Factored out of the workflow so
+# the failure-isolation logic lives in one shellcheck-clean place.
+#
+# Usage:
+# serve-unsloth-run.sh --model REPO --gguf-variant VAR --port PORT \
+# [--gguf-file PATH] [--extra "--seed 3407 --temp 0"] \
+# [--log-dir logs] [--health-timeout 300]
+#
+# Why a helper and not inline YAML
+# --------------------------------
+# * Every `unsloth run` invocation here is the *Unsloth server* under test.
+# A failure to come up healthy is class (a) "server/API regression" and
+# must be reported with a distinct `::error::` BEFORE any agent runs.
+# * The banner is the documented contract a human copies from. We parse the
+# exact `API Key:` line printed by unsloth_cli/commands/studio.py
+# (` API Key: ` non-silent, `API Key: ` silent) so a
+# silent change to that line is also caught.
+# * `unsloth run` re-execs into the studio venv ($STUDIO_HOME/unsloth_studio),
+# so in CI after `install.sh --local` it runs the PR's repo code.
+#
+# Outputs written to $GITHUB_ENV (and echoed):
+# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner
+# UNSLOTH_STUDIO_URL http://127.0.0.1: (so `unsloth connect`
+# finds THIS server, not the hardcoded :8888)
+# UNSLOTH_BASE_URL same as UNSLOTH_STUDIO_URL (alias for clarity)
+# UNSLOTH_MODEL_ID the canonical id reported by /v1/models
+# UNSLOTH_SERVER_PID pid of the backgrounded `unsloth run`
+# UNSLOTH_LLAMA_LOG_DIR ~/.unsloth/studio/logs/llama-server
+
+set -uo pipefail
+
+# ── arg parse ────────────────────────────────────────────────────────────
+MODEL=""
+GGUF_VARIANT=""
+GGUF_FILE=""
+PORT=""
+EXTRA=""
+LOG_DIR="logs"
+HEALTH_TIMEOUT="300"
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --model) MODEL="$2"; shift 2 ;;
+ --gguf-variant) GGUF_VARIANT="$2"; shift 2 ;;
+ --gguf-file) GGUF_FILE="$2"; shift 2 ;;
+ --port) PORT="$2"; shift 2 ;;
+ --extra) EXTRA="$2"; shift 2 ;;
+ --log-dir) LOG_DIR="$2"; shift 2 ;;
+ --health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;;
+ *) echo "serve-unsloth-run.sh: unknown arg '$1'" >&2; exit 2 ;;
+ esac
+done
+
+[ -n "$PORT" ] || { echo "serve-unsloth-run.sh: --port is required" >&2; exit 2; }
+if [ -z "$MODEL" ] && [ -z "$GGUF_FILE" ]; then
+ echo "serve-unsloth-run.sh: one of --model or --gguf-file is required" >&2
+ exit 2
+fi
+
+mkdir -p "$LOG_DIR"
+SERVER_LOG="$LOG_DIR/unsloth-run-${PORT}.log"
+BASE_URL="http://127.0.0.1:${PORT}"
+STUDIO_HOME_DIR="${STUDIO_HOME:-$HOME/.unsloth/studio}"
+LLAMA_LOG_DIR="${STUDIO_HOME_DIR}/logs/llama-server"
+
+# Emit a key=value pair to $GITHUB_ENV when set, always echo for local runs.
+emit() {
+ echo "$1=$2"
+ if [ -n "${GITHUB_ENV:-}" ]; then
+ echo "$1=$2" >> "$GITHUB_ENV"
+ fi
+}
+
+server_fail() {
+ echo "::error::Unsloth server/API regression: $*" >&2
+ echo "---- last 200 lines of $SERVER_LOG ----" >&2
+ tail -200 "$SERVER_LOG" 2>/dev/null || true
+ exit 1
+}
+
+# ── port collision guard ─────────────────────────────────────────────────
+# A leftover listener (or a parallel matrix cell that wandered onto our port)
+# would make us attach to the wrong server and mask a real regression. Fail
+# fast instead.
+if command -v ss >/dev/null 2>&1; then
+ if ss -tln 2>/dev/null | grep -q ":${PORT}\b"; then
+ server_fail "port ${PORT} already has a listener before we started (collision)"
+ fi
+fi
+
+# ── build the command ────────────────────────────────────────────────────
+# `unsloth run` == alias of `unsloth studio run`. --disable-tools is REQUIRED
+# (passthrough mode) so the agent's own tools relay instead of the server's.
+# --no-cloudflare keeps us off the network (loopback bind, no tunnel attempt).
+CMD=(unsloth run -H 127.0.0.1 -p "$PORT" --disable-tools --no-cloudflare)
+if [ -n "$GGUF_FILE" ]; then
+ CMD+=(--model "$GGUF_FILE")
+else
+ CMD+=(--model "$MODEL")
+ [ -n "$GGUF_VARIANT" ] && CMD+=(--gguf-variant "$GGUF_VARIANT")
+fi
+# Determinism knobs + any caller passthrough (e.g. --seed 3407 --temp 0).
+# shellcheck disable=SC2206 # intentional word-split of caller-controlled flags
+[ -n "$EXTRA" ] && CMD+=($EXTRA)
+
+echo "[serve] launching: ${CMD[*]}"
+echo "[serve] server log: $SERVER_LOG"
+
+# Run detached, no controlling TTY (setsid avoids any TTY-prompt hang and
+# detaches from this step's process group so the job's teardown is clean).
+setsid "${CMD[@]}" > "$SERVER_LOG" 2>&1 < /dev/null &
+SERVER_PID=$!
+emit UNSLOTH_SERVER_PID "$SERVER_PID"
+
+# ── wait for /api/health == healthy ──────────────────────────────────────
+HEALTHY=0
+for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
+ server_fail "process exited before becoming healthy (pid $SERVER_PID)"
+ fi
+ if curl -fs "${BASE_URL}/api/health" -o "$LOG_DIR/health-${PORT}.json" 2>/dev/null; then
+ if jq -e '.status == "healthy"' "$LOG_DIR/health-${PORT}.json" >/dev/null 2>&1; then
+ HEALTHY=1
+ break
+ fi
+ fi
+ sleep 1
+done
+[ "$HEALTHY" = "1" ] || server_fail "did not report /api/health healthy within ${HEALTH_TIMEOUT}s"
+echo "[serve] /api/health healthy"
+
+# ── parse the API key from the banner ────────────────────────────────────
+# Match both the non-silent " API Key: " and silent "API Key: "
+# forms. We do NOT trust a fixed column count; we take the sk-unsloth-* token.
+API_KEY=""
+for _ in $(seq 1 30); do
+ API_KEY="$(grep -aoE 'sk-unsloth-[A-Za-z0-9_-]+' "$SERVER_LOG" 2>/dev/null | head -1 || true)"
+ [ -n "$API_KEY" ] && break
+ sleep 1
+done
+if [ -z "$API_KEY" ]; then
+ # Fallback: take whatever follows an "API Key:" label, in case the key
+ # prefix scheme changes. Still a parse-fragility guard, not silent.
+ API_KEY="$(grep -aE 'API Key:' "$SERVER_LOG" 2>/dev/null \
+ | sed -E 's/.*API Key:[[:space:]]*//' | head -1 || true)"
+fi
+[ -n "$API_KEY" ] || server_fail "could not parse an API key from the banner (banner-parse fragility -- check the 'API Key:' line in unsloth_cli/commands/studio.py)"
+echo "::add-mask::${API_KEY}"
+emit UNSLOTH_API_KEY "$API_KEY"
+
+# ── resolve /v1/models id ────────────────────────────────────────────────
+if ! curl -fs "${BASE_URL}/v1/models" \
+ -H "Authorization: Bearer ${API_KEY}" -o "$LOG_DIR/models-${PORT}.json" 2>/dev/null; then
+ server_fail "/v1/models did not respond (or rejected the banner key)"
+fi
+MODEL_ID="$(jq -r '.data[0].id // empty' "$LOG_DIR/models-${PORT}.json" 2>/dev/null || true)"
+[ -n "$MODEL_ID" ] || server_fail "/v1/models returned no model id (model failed to load)"
+echo "[serve] resolved model id: $MODEL_ID"
+
+emit UNSLOTH_MODEL_ID "$MODEL_ID"
+emit UNSLOTH_STUDIO_URL "$BASE_URL"
+emit UNSLOTH_BASE_URL "$BASE_URL"
+emit UNSLOTH_LLAMA_LOG_DIR "$LLAMA_LOG_DIR"
+
+echo "[serve] server is up: ${BASE_URL} (model ${MODEL_ID})"
diff --git a/.github/workflows/local-agent-guides-ci.yml b/.github/workflows/local-agent-guides-ci.yml
new file mode 100644
index 0000000000..150dbc3fde
--- /dev/null
+++ b/.github/workflows/local-agent-guides-ci.yml
@@ -0,0 +1,599 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+# Local Agent Guides CI
+# =====================
+# Detects when our local-agent setup recipes drift out of sync with
+# `unsloth run`. Boots a real `unsloth run --disable-tools` server and
+# drives the coding agents end to end through the *exact* recipes defined
+# in unsloth_cli/commands/connect.py (the in-repo source of truth -- there
+# is no docs/ tree). Wherever connect.py has a recipe we drive the agent
+# via `unsloth connect --no-launch` and execute what it prints, so
+# the test self-updates against connect.py and catches silent recipe drift.
+#
+# Source-of-truth files this workflow guards:
+# unsloth_cli/commands/connect.py the `unsloth connect ` recipes
+# unsloth_cli/commands/studio.py the `unsloth run` banner (API Key line)
+#
+# Failure taxonomy (each surfaced with a distinct ::error:: + the agent name
+# + the connect.py location, so a red X is immediately triageable):
+# (a) Unsloth server/API regression -- the dialect HTTP preflight fails
+# BEFORE the agent runs (or the server never becomes healthy).
+# (b) Agent package install failed -- npm/curl install of the CLI failed.
+# (c) Guide drift -- preflight passed + install ok, but
+# the documented `unsloth connect` flow produced no/garbled output.
+#
+# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi.
+# - claude/codex/hermes/openclaw/opencode have a connect.py recipe.
+# - pi has NO `unsloth connect pi` command in connect.py at HEAD; it is
+# driven by a hand-written recipe and the matrix cell asserts that the
+# missing connect recipe is the (known) reason, so the day connect.py
+# grows a `pi` command this cell flips to the self-updating path.
+
+name: Local Agent Guides CI
+
+on:
+ # Off-peak weekly, deliberately a NON-:00 minute to dodge the top-of-hour
+ # GitHub-hosted-runner stampede.
+ schedule:
+ - cron: '37 7 * * 1'
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - 'unsloth_cli/**'
+ - 'studio/backend/routes/**'
+ # Contracts this workflow asserts that live outside routes/**: the
+ # /api/health endpoint, the llama-server KV-cache log behavior, and the
+ # request/response schemas the agent dialects depend on.
+ - 'studio/backend/main.py'
+ - 'studio/backend/core/inference/llama_cpp.py'
+ - 'studio/backend/models/**'
+ - 'install.sh'
+ - '.github/workflows/local-agent-guides-ci.yml'
+ - '.github/scripts/serve-unsloth-run.sh'
+ - '.github/scripts/assert-prompt-cache.sh'
+ - '.github/scripts/agent-guides-install.sh'
+ - '.github/scripts/agent-guides-drive.sh'
+ - '.github/scripts/ci-connect-prompt.txt'
+ - '.github/scripts/ci-min-system-prompt.txt'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ # Determinism precedent (studio-inference-smoke.yml): temp 0 + fixed seed.
+ UNSLOTH_SEED: '3407'
+ # A single invoke must never hang the runner on a headless TTY prompt. With
+ # prefill-shrinking flags (minimal system prompt + restricted tools) a turn on
+ # a 4B model finishes in a couple of minutes on CPU; this also caps how long a
+ # still-large-prompt agent burns before failing. Well under the 6h job cap.
+ AGENT_INVOKE_TIMEOUT: '600'
+
+jobs:
+ # ═════════════════════════════════════════════════════════════════════
+ # Job 1: connection
+ # Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect,
+ # install the agent, run `unsloth connect --no-launch`, execute
+ # the emitted recipe with a trivial prompt, assert a non-empty reply.
+ # Runs on PR + weekly + dispatch. Each matrix cell is its own runner so
+ # it serves exactly one model on its own port.
+ # ═════════════════════════════════════════════════════════════════════
+ connection:
+ name: connection (${{ matrix.agent }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ strategy:
+ fail-fast: false
+ matrix:
+ agent: [claude, codex, hermes, openclaw, opencode, pi]
+ include:
+ # OpenClaw needs Node 24; everything else is happy on 22.
+ - agent: openclaw
+ node: '24'
+ env:
+ # gemma-4-E4B (128K context, capable enough to drive every agent for a
+ # trivial reply; the 270m model produced empty/failed responses for
+ # codex/openclaw and is below hermes' 64K context floor). Served as a flat
+ # GGUF file (the -MTP- repo ships no separate draft, so this is plain 4B).
+ GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
+ GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
+ STUDIO_PORT: '18901'
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Linux deps for llama.cpp prebuilt
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libcurl4-openssl-dev libssl-dev jq
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: ${{ matrix.node || '22' }}
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+
+ - name: Restore GGUF model file
+ id: cache-gguf
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ continue-on-error: true
+ with:
+ path: gguf-cache
+ key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
+
+ - name: Download GGUF if cache miss
+ id: download-gguf
+ if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
+ env:
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ python -m pip install --upgrade huggingface_hub
+ mkdir -p gguf-cache
+ bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
+
+ - name: Save GGUF model file
+ if: always() && steps.download-gguf.outcome == 'success'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: gguf-cache
+ key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
+
+ - name: Install Studio (--local, --no-torch)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ mkdir -p logs
+ set -o pipefail
+ bash install.sh --local --no-torch 2>&1 | tee logs/install.log
+
+ # ── boot the server under test (factored helper) ──────────────────
+ - name: Serve unsloth run --disable-tools (gemma-4-E4B)
+ run: |
+ unsloth studio reset-password
+ bash .github/scripts/serve-unsloth-run.sh \
+ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
+ --port "$STUDIO_PORT" --log-dir logs \
+ --extra "--seed $UNSLOTH_SEED --temp 0" \
+ --health-timeout 900
+
+ # ── (a) server/API preflight: prove the dialect works BEFORE the agent ─
+ # Distinct error class. If this step fails it is a SERVER regression,
+ # not the agent's or the guide's fault, and the agent steps never run.
+ - name: Preflight the agent's API dialect (class-a isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: |
+ set -uo pipefail
+ B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
+ preflight_fail() {
+ echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
+ exit 1
+ }
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
+ -H "Authorization: Bearer $K") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
+ case "$AGENT" in
+ claude)
+ # Anthropic Messages dialect.
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
+ ;;
+ codex)
+ # Codex always streams /v1/responses.
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
+ ;;
+ *)
+ # OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
+ # OpenClaw's connect.py recipe writes an "openai-completions"
+ # provider (write_openclaw_config), so it uses this path, not
+ # /v1/messages.
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
+ ;;
+ esac
+ echo "preflight OK for $AGENT"
+
+ # ── (b) install the agent CLI (hardened npm/curl, retried) ─────────
+ - name: Install agent CLI (class-b isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: bash .github/scripts/agent-guides-install.sh "$AGENT"
+
+ # ── (c) drive the agent via connect.py and assert a reply ──────────
+ # For the 5 agents with a connect.py recipe we run
+ # `unsloth connect --no-launch`, eval its env/unset exports,
+ # then run the printed command with a hard timeout (no headless-TTY
+ # hang). Pi has no connect recipe, so it is driven by hand and the
+ # cell asserts that absence is the (known) reason.
+ - name: Drive ${{ matrix.agent }} via unsloth connect (class-c isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT"
+
+ - name: Collect server logs (debug)
+ if: always()
+ run: |
+ mkdir -p logs/studio-logs
+ cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
+ # Redact the key across the WHOLE logs/ tree, not just studio-logs:
+ # serve-unsloth-run.sh records the `unsloth run` banner (which prints
+ # `API Key: `) into logs/unsloth-run-.log, and the upload
+ # step publishes all of logs/, so scrubbing only studio-logs would leak
+ # the bearer token in the retained artifact.
+ if [ -n "${UNSLOTH_API_KEY:-}" ]; then
+ grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
+ sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
+ done
+ fi
+
+ - name: Stop Studio
+ if: always()
+ run: |
+ # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
+ # `kill 0` signal this step's whole process group and abort cleanup.
+ if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
+ kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
+ fi
+ sleep 2
+ ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
+
+ - name: Upload logs
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: connection-${{ matrix.agent }}-log
+ path: |
+ logs/
+ redacted-configs/
+ retention-days: 7
+
+ # ═════════════════════════════════════════════════════════════════════
+ # Job 2: file-edit
+ # The deterministic 2-turn hello.py test on Qwen3.5-4B (smaller models
+ # can't reliably drive the heavyweight agents' edit flows). Weekly +
+ # dispatch only -- it is the slow, model-heavy job and must not gate PRs.
+ # ═════════════════════════════════════════════════════════════════════
+ file-edit:
+ name: file-edit (${{ matrix.agent }})
+ if: github.event_name != 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ # hermes and openclaw drive a multi-turn tool loop that a CPU-only runner
+ # cannot finish in time (e.g. openclaw holds its 300s session-write-lock past
+ # expiry; each turn re-prefills the tool prompt at ~16 tok/s). Their endpoint
+ # wiring + generation are already hard-gated by the connection job, so the
+ # file-edit cell is best-effort here -- it still runs and uploads logs, but a
+ # timeout does not fail the workflow. Drop best_effort (or move e2e to a GPU
+ # runner) to make it blocking again.
+ continue-on-error: ${{ matrix.best_effort || false }}
+ strategy:
+ fail-fast: false
+ matrix:
+ agent: [claude, codex, hermes, openclaw, opencode, pi]
+ include:
+ - agent: openclaw
+ node: '24'
+ best_effort: true
+ - agent: hermes
+ best_effort: true
+ env:
+ # gemma-4-E4B served as a flat GGUF file (cache size tracks the .gguf 1:1,
+ # no xet-chunk inflation; the -MTP- repo ships no separate draft file).
+ GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
+ GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
+ STUDIO_PORT: '18902'
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Linux deps for llama.cpp prebuilt
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libcurl4-openssl-dev libssl-dev jq
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: ${{ matrix.node || '22' }}
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+
+ - name: Restore GGUF model file
+ id: cache-gguf
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ continue-on-error: true
+ with:
+ path: gguf-cache
+ key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
+
+ - name: Download GGUF if cache miss
+ id: download-gguf
+ if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
+ env:
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ python -m pip install --upgrade huggingface_hub
+ mkdir -p gguf-cache
+ bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
+
+ - name: Save GGUF model file
+ if: always() && steps.download-gguf.outcome == 'success'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: gguf-cache
+ key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
+
+ - name: Install Studio (--local, --no-torch)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ mkdir -p logs
+ set -o pipefail
+ bash install.sh --local --no-torch 2>&1 | tee logs/install.log
+
+ - name: Serve unsloth run --disable-tools (gemma-4-E4B)
+ run: |
+ unsloth studio reset-password
+ bash .github/scripts/serve-unsloth-run.sh \
+ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
+ --port "$STUDIO_PORT" --log-dir logs \
+ --extra "--seed $UNSLOTH_SEED --temp 0" \
+ --health-timeout 900
+
+ - name: Preflight the agent's API dialect (class-a isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: |
+ set -uo pipefail
+ B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
+ preflight_fail() {
+ echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
+ exit 1
+ }
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
+ -H "Authorization: Bearer $K") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
+ # Probe the same dialect the agent will use, so a streaming/messages
+ # regression in the weekly run is reported as class (a) here instead of
+ # surfacing later as guide drift (mirrors the connection job).
+ case "$AGENT" in
+ claude)
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
+ ;;
+ codex)
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
+ ;;
+ *)
+ # OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
+ ;;
+ esac
+ echo "preflight OK for $AGENT"
+
+ - name: Install agent CLI (class-b isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: bash .github/scripts/agent-guides-install.sh "$AGENT"
+
+ - name: 2-turn hello.py test (class-c isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: bash .github/scripts/agent-guides-drive.sh file-edit "$AGENT"
+
+ - name: Collect server logs (debug)
+ if: always()
+ run: |
+ mkdir -p logs/studio-logs
+ cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
+ # Redact the key across the WHOLE logs/ tree, not just studio-logs:
+ # serve-unsloth-run.sh records the `unsloth run` banner (which prints
+ # `API Key: `) into logs/unsloth-run-.log, and the upload
+ # step publishes all of logs/, so scrubbing only studio-logs would leak
+ # the bearer token in the retained artifact.
+ if [ -n "${UNSLOTH_API_KEY:-}" ]; then
+ grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
+ sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
+ done
+ fi
+
+ - name: Stop Studio
+ if: always()
+ run: |
+ # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
+ # `kill 0` signal this step's whole process group and abort cleanup.
+ if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
+ kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
+ fi
+ sleep 2
+ ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
+
+ - name: Upload logs
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: file-edit-${{ matrix.agent }}-log
+ path: |
+ logs/
+ agent-workdir/
+ redacted-configs/
+ retention-days: 7
+
+ # ═════════════════════════════════════════════════════════════════════
+ # Job 3: prompt-cache
+ # (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0
+ # (server prompt-cache sanity).
+ # (b) Claude Code attribution A/B: with CLAUDE_CODE_ATTRIBUTION_HEADER=0
+ # expect a llama-server KV-cache HIT on turn 2; without it expect a
+ # MISS. If it inverts, the guide flag is stale.
+ # PR + weekly + dispatch (cheap, gemma-3-270m).
+ # ═════════════════════════════════════════════════════════════════════
+ prompt-cache:
+ name: prompt-cache (gemma-3-270m)
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ env:
+ GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
+ GGUF_VARIANT: UD-Q4_K_XL
+ GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
+ STUDIO_PORT: '18903'
+ HF_HOME: ${{ github.workspace }}/hf-cache
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Linux deps for llama.cpp prebuilt
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libcurl4-openssl-dev libssl-dev jq
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: '22'
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+
+ - name: Restore HF_HOME for ${{ env.GGUF_REPO }}
+ id: cache-hf
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ continue-on-error: true
+ with:
+ path: hf-cache
+ key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
+
+ - name: Prime HF_HOME with the GGUF
+ id: prime-hf
+ if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
+ env:
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ python -m pip install --upgrade huggingface_hub
+ mkdir -p hf-cache
+ bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
+
+ - name: Save HF_HOME for ${{ env.GGUF_REPO }}
+ if: always() && steps.prime-hf.outcome == 'success'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: hf-cache
+ key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
+
+ - name: Install Studio (--local, --no-torch)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ mkdir -p logs
+ set -o pipefail
+ bash install.sh --local --no-torch 2>&1 | tee logs/install.log
+
+ - name: Serve unsloth run --disable-tools (gemma-3-270m)
+ run: |
+ unsloth studio reset-password
+ bash .github/scripts/serve-unsloth-run.sh \
+ --model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
+ --port "$STUDIO_PORT" --log-dir logs \
+ --extra "--seed $UNSLOTH_SEED --temp 0"
+
+ # (a) server prompt-cache sanity on the OpenAI chat path. The helper runs
+ # the 2-turn probe internally (turn 2 reuses turn 1's prefix) and asserts
+ # turn-2 usage.prompt_tokens_details.cached_tokens > 0. This is the hard
+ # gate -- it proves llama.cpp KV reuse is surfaced on /v1/chat/completions.
+ - name: Server prompt-cache sanity (cached_tokens > 0)
+ run: bash .github/scripts/assert-prompt-cache.sh api "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY"
+
+ - name: Install Claude Code (class-b isolation)
+ env:
+ AGENT: claude
+ run: bash .github/scripts/agent-guides-install.sh claude
+
+ # (b) Claude attribution A/B against the llama-server log. This is the most
+ # environment-sensitive check (it depends on the bundled llama.cpp's
+ # slot-reuse log wording and on claude --continue reusing the prefix), so
+ # it is non-blocking until calibrated on the first scheduled run; the
+ # server cache sanity above is the hard gate. The step still prints the
+ # observed HIT/MISS so drift is visible in the log + artifacts.
+ - name: Claude attribution A/B (HIT with header=0, MISS without)
+ continue-on-error: true
+ run: bash .github/scripts/agent-guides-drive.sh attribution-ab claude
+
+ - name: Collect server logs (debug)
+ if: always()
+ run: |
+ mkdir -p logs/studio-logs
+ cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
+ # Redact the key across the WHOLE logs/ tree, not just studio-logs:
+ # serve-unsloth-run.sh records the `unsloth run` banner (which prints
+ # `API Key: `) into logs/unsloth-run-.log, and the upload
+ # step publishes all of logs/, so scrubbing only studio-logs would leak
+ # the bearer token in the retained artifact.
+ if [ -n "${UNSLOTH_API_KEY:-}" ]; then
+ grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
+ sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
+ done
+ fi
+
+ - name: Stop Studio
+ if: always()
+ run: |
+ # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
+ # `kill 0` signal this step's whole process group and abort cleanup.
+ if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
+ kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
+ fi
+ sleep 2
+ ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
+
+ - name: Upload logs
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: prompt-cache-log
+ path: |
+ logs/
+ redacted-configs/
+ retention-days: 7
From 44d6727c6559163d6c3fd310701f40a62a666a92 Mon Sep 17 00:00:00 2001
From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
Date: Mon, 22 Jun 2026 04:33:04 -0700
Subject: [PATCH 017/306] Studio: redesign Select model dropdown to match Hub
design (#6364)
* Studio: redesign Select model dropdown to match Hub design
Make the chat Select model picker easier to scan by reusing the Hub
on-device card's visual language.
- Rows now split owner/name, add a param chip, a DotTag format pill,
a tabular size, and a Loaded marker on the active model.
- Hub models / Fine-tuned tabs reuse the Hub's exact .hub-tab-toggle
styling (selectors extended in hub.css to the selector menu).
- Add a Downloaded / Recommended / Custom section toggle on the Hub
tab to filter the list.
- Widen the popover and nudge the scrollbar toward the edge.
* Studio: move section toggle below search, size tabs to label
Put Downloaded / Recommended / Custom under the search bar in their own
row so Hub models / Fine-tuned no longer wrap. The section toggle uses a
smaller font and sizes each tab to its label instead of equal widths.
* Studio: extract pure row-meta helpers into their own module
Move splitRepoLabel, classifyMetaToken, and parseMetaTokens out of
pickers.tsx into row-meta.ts. No behaviour change; keeps the presentation
logic free of React/DOM deps so it is easy to test in isolation.
* Studio: content-size the source tabs and add section icons
Size the Hub models / Fine-tuned tabs to their labels (with side
padding) like the section toggle, instead of stretching full width. Add
a leading download, star, and folder icon to Downloaded, Recommended,
and Custom.
* Studio: stop source tabs stretching and hide empty Fine-tuned tab
The popover is a flex column, so the fit toggle stretched full width;
add w-fit/self-start so it sizes to its content. Also hide the
Fine-tuned tab when there are no fine-tuned models, defaulting to Hub
models.
* Studio: keep only fine-tuned models in the Fine-tuned tab
Local models (LM Studio, Ollama, custom folders) carry source "local"
and already show in the Hub tab's Downloaded / Custom sections, so
exclude them from the Fine-tuned tab and from its visibility count.
Extract the tab rules into source-tabs.ts.
* Studio: show local providers under Downloaded, Recommended first
Show LM Studio and other local provider models in the Downloaded
section in all modes (was chat-only). Put Recommended first and make it
the default section. Add a little more space below the search bar.
* Studio: make Recommended a sortable live Unsloth listing
Replace the static Recommended list (and its collapse chevron) with a
sort dropdown over Unsloth's own models: Recommended, Trending, Most
likes, Downloads, Recently updated. Recommended shows recently uploaded
GGUF/MLX models that fit the device (hidden if they do not); the other
sorts list all Unsloth models, badged but never hidden. Adds a sort
option to useHfModelSearch and a pure recommended-fit helper.
* Studio: size Recommended models from the repo name when metadata is missing
GGUF and MLX repos rarely expose safetensors metadata, so a large model
with no size could pass the Recommended fit check because unknown size was
treated as fitting. Parse the parameter count from the repo id, including
the Gemma E series, and hide anything we still cannot size.
* Studio: detect model capabilities and family from HF tags
Thread tags and the pipeline tag through the model search results and add a
pure helper that infers vision, reasoning and audio plus the architecture
family, falling back to repo-name keywords when tags are absent.
* Studio: add row details and inline section sorting to Select model
Give each model row more detail and make the Hub sections easier to scan:
- Show vision, reasoning and audio badges plus the architecture family tag
on each row, alongside the params, format and size.
- Drop the redundant unsloth/ prefix on the Recommended rows.
- Rename the Recommended section tab to Unsloth and enlarge the section tabs.
- Move the sort dropdown inline to the right of the tabs at a fixed width.
- Add Recent, Size and Downloaded sorting to the Downloaded and Custom tabs.
- Remove the header icons, pad the subheadings, and grow the list height.
* Studio: tune the Select model sort dropdown and trim row badges
- Recommended now lists the most recently created Unsloth repos.
- Narrow the sort dropdown, remove its border, and truncate long labels.
- Tighten the gap between the section tab icons and their labels.
- Remove the architecture family tag from rows since it repeats the name.
* Studio: extract the PillTabs toggle into a shared module
Move the segmented pill toggle out of the model selector into its own file so
the Hub picker can reuse it for a format filter without duplicating the markup.
* Studio: fix Recommended infinite scroll and add a format filter
- Re-attach the scroll observer on each loaded page so a filtered Recommended
list keeps paging until the viewport fills instead of spinning forever with
nothing new appearing.
- Add an All / GGUF / MLX / Safetensors toggle on the Unsloth listing that
filters every sort.
* Studio: default Recommended to Trending, rename Downloaded to On Device, and fade the scroll edge
Sort: default the Recommended view to Trending and add a Name option to
the On Device / Custom sort. Recent now orders by last load time while
Downloaded orders by file date, tracked in localStorage (model-usage.ts).
Formats: show the format filter on all three tabs (Unsloth, On Device,
Custom), exclude mobile GGUF builds from Recommended, and flag GGUF rows
that exceed the device with the same OOM badge as safetensors.
Polish: download-icon badge on already-downloaded Recommended rows, the
hugeicons view stroke-rounded vision badge, Search all models placeholder,
matched popover padding, and a top-edge mask fade once the list scrolls.
* Studio: size GGUF repos from gguf metadata so large ones flag OOM
Repos with no B token in the name (Kimi, MiniMax) had no param count
and so never showed an OOM badge. Request the gguf expand field from
Hugging Face and read gguf.total, so those repos get a param chip and an
OOM badge when they exceed the device budget.
Keep the row name full contrast when over budget (the OOM badge already
signals the fit), shorten the format and sort dropdowns, narrow the
popover, and rename Recently updated to Recent and All formats to All.
* Studio: address selector review feedback
Add WAI-ARIA roving tabindex and Arrow Left/Right navigation to the pill
toggle so only the active tab is in the tab order. Keep the chat-only
GGUF/MLX filter for every Recommended sort, not just Recommended, so
chat-only users do not see unrunnable checkpoints under Trending. Feed
both listings' GGUF hints into repo detection so a tag-only GGUF in
Recommended expands variants instead of loading as a checkpoint.
* Studio: scope Select model search per tab and add an MLX tag
Search is now per section. The Unsloth tab searches the Unsloth HF
listing only, On Device filters downloaded and LM Studio models by name,
and Custom filters custom-folder models, each with its own empty state.
MLX repos get an MLX pill mirroring the GGUF tag. Downloaded quants in
the Unsloth and search lists get the same delete action as On Device.
Also: revert the model name to normal weight, narrow the popover to
558px so the format and sort dropdowns sit one gap-2 from the tabs,
tighten the dropdown menus to match the Projects activity Select, and
make the empty On Device state name the active format filter.
* Studio: show local ./models on the On Device tab so they stay selectable
Models under the local models directory (source models_dir) flow in as local
models but were dropped from every list: filtered out of Fine-tuned and never
re-added by the Hub picker, which kept only LM Studio and custom-folder
sources. Capture them in the local refresh and render a Local models group on
the On Device tab, with the same format, search, and chat-only GGUF rules as
the other local groups.
* Studio: add a Hub button beside the Select model search bar
Adds a Hub button next to the search bar that opens the full Hub Discover
page to browse more models. Styled like the section tabs (rounded, no
border, soft shadow with a faint top layer) and darkens on hover. Also
nudges the format and sort dropdown chevrons a touch toward the edge.
* Studio: align Select model padding and tighten the format pills
Sizes the popover to the tab cluster so the left and right padding match,
and drops the top row below the rounded corner so the Hub button lines up
with the Trending dropdown. Gives the Hub button a fixed width, lets the
list scrollbar sit inside the box, and shrinks the format pill dot with a
tighter dot-to-label gap.
* Studio: label the Hub button Search Hub and match the dropdown width
Renames the button to Search Hub, sets its width to the format and sort
dropdown width so it lines up above them, and tightens the icon gap.
* Studio: drop the vision and reasoning row badges to declutter
Removes the vision and reasoning capability icons from the model rows so
they read cleaner. Audio is kept.
* Studio: add a safetensors pill, hide diffusion models, eye on Vision
Gives safetensors rows a format pill and size so their meta matches GGUF
and MLX, drops image and video diffusion models from the listing since they
cannot run in chat, and shows an eye icon next to the Vision tag. Also
removes the em dashes from the Projects export and import labels.
* Studio: gate recommended folders on real weights and polish the selector
Only show a Recommended chip once the well-known dir actually holds
weights, so an empty LM Studio or Ollama scaffold no longer suggests
itself. _dir_has_downloaded_model checks for a GGUF/safetensors file or
a non-empty Ollama manifests store, with a bounded walk.
Selector polish: round the popover and option menus a touch more,
lighten the OOM badge in dark mode, soften the inner dropdown shadow,
even out the padding, and lift the toggle track and field triggers so
their edges read against the popover.
Also catch CogVideoX in the diffusion name fallback.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: align the dark Select model panel with the sidebar
Match the popover, fields, dropdowns, tab toggle and row states to the
sidebar surface and accent so the dropdown reads as one piece in dark
mode. The active tab pill and Search Hub button sit a touch lighter
than the track, and the inner option menus drop their drop shadow for a
flatter look. Light mode is unchanged.
* Studio: re-derive the Select model tab on open
The picker remounts each time the dropdown opens, but the source tab
state did not, so a persisted fine-tuned or connected selection that
only lands in its list after an async load would reopen on Hub. Reset
the active tab to the selection-derived default on the open edge, while
still letting the user switch tabs freely within a session.
* Studio: fold Custom into On Device and polish the picker
Merge the Custom tab into On Device so custom folders sit right below
the downloaded models, with a folder shortcut on the group header.
Rename the first Hub tab to Recommended, give the format dropdown
colored dots, even out the tab row spacing, and tighten the popover
width. Align the folder browser with the app dialogs (soft surface,
roomier padding, green confirm, grey hover).
* Studio: fix On Device controls and nudge the folder browser close
The Hub redesign merge dropped the old Search Hub button styling, so the
On Device search row rendered flat. Point the search input and Search
Hub button at the shared .field-soft surface so they match the rest of
the Hub controls, and lift the folder browser close button slightly.
* Studio: run the Select model search on the Hub search stack
Point the picker at the Hub's useHubModelSearch and useHubInfiniteScroll
instead of its own useHfModelSearch/useInfiniteScroll, scoped to unsloth
so the listing matches the old one. Both the search and the recommended
feed now share the Hub implementation, so there is one search path. The
Hub result folds GGUF params into totalParams, so the dead ggufParams
fallback is dropped.
* Studio: trim the recommended sort to Recommended, Trending, Recent
Drop Downloads and Most likes from the sort dropdown.
* Studio: give the section tabs room off the rounded edge
The fit-mode toggle wrapped the tabs with no inset, so On Device sat
tight against the rounded-full edge. Add a small horizontal inset and
widen the popover a touch to fit it.
* Studio: drop the legacy HF search hooks for the Hub ones
Migrate the training model and dataset sections, export page, onboarding
steps and recipe dataset combobox off useHfModelSearch, useHfDatasetSearch
and useInfiniteScroll onto the Hub equivalents, scoped to unsloth so the
listings match. The picker reads recommended param counts off the search
results it already has instead of a separate fetch. Removes the duplicate
search stack: use-hf-model-search, use-hf-dataset-search,
use-hf-paginated-search, use-infinite-scroll, use-recommended-model-vram
and the old lib/hf-cache.
* Fix model selector section toggle proportions
Remove the fit-mode track inset so the active pill sits flush to the
track edge, matching the Hub's segmented controls.
* Tighten model selector width and tab padding
Reduce the popover width so the right edge aligns with the row, and
widen the fit-mode tab padding so On Device clears the track edge.
* Refine Recommended formats, sort width and tab padding
Recommended now suggests GGUF anywhere and MLX only on Mac, never
safetensors. Size the sort dropdown to its label so Recommended no
longer truncates, and match the On Device trailing gap to the active
pill's leading inset.
* Flush section toggle and match dropdown font to Search Hub
Drop the trailing track pad so the active pill fits the track exactly
at either end. Size the sort and format dropdown text to text-xs like
the Search Hub button, and clip long labels without an ellipsis.
* Fix sort menu checkmark overlap and lock dropdown widths
Keep the option's right padding so the selected checkmark no longer
overlaps the label, and let the open menu expand to fit it. Set the
format and sort triggers to a fixed width matching the Search Hub
button so they always line up.
* Keep section toggle and dropdowns on one row
Drop the wrap and size the Search Hub button, format and sort dropdowns
to a shared 100px so they stay equal width and fit on one row without
widening the box.
* Studio: pre-load inference settings dialog with native context
Add a gear on downloaded GGUF quant rows that opens a settings dialog
to adjust inference parameters before loading a model:
- Context length, KV cache dtype, speculative decoding and tensor
parallelism, all written to the runtime store the load call reads.
- Settings can be remembered per model in localStorage.
- The context slider ceiling and "Model supports up to N tokens" come
from the model's native context, read from GGUF metadata and returned
by /api/models/gguf-variants once a variant is downloaded.
Also drop models Studio can't run for chat (diffusion, image, video)
from the recommended feed and Hub search, plus minor selector polish
on row hover padding, Search Hub and dropdown widths, and tab spacing.
* Studio: model selector polish and memory-aware load warning
Search and listing:
- Drop the "Recommended" and "Hugging Face" section labels while
searching so results read as one list; keep the format and sort
dropdowns visible so search results can still be sorted and filtered.
- Request gguf metadata in the Hub listing so GGUF repos report a
parameter count, restoring the OOM badge for repos without a size
token in the name (Kimi, MiniMax, GLM).
Load settings dialog:
- Warn when weights plus the KV cache at the chosen context exceed
available memory. The KV size is sized by the backend's
architecture-aware estimator via a new kv-cache-estimate endpoint;
the budget uses VRAM plus system RAM. Best-effort, no warning on
failure or on auto context.
- Context Length placeholder reads "auto"; dark background slightly
lighter.
Other:
- Clicking the Custom Folders header opens the folder browser; its
title now reads "Select folder to detect models".
- On Device sort lists Downloaded last.
- Smaller chat template editor font; rounded wrapper clips the prompt
and template editor scrollbars so the right corners stay round.
* Studio: fix load dialog memory warning budget and KV dropdown width
- The memory warning never fired without a discrete GPU. useGpuInfo
returned zero system RAM in that case, so the budget was always zero.
Surface system RAM even when no GPU is present (Mac unified memory),
and have the load dialog read memory directly instead of through props.
- Give the dialog fields shrink-0 so the KV Cache Dtype value (e.g.
q8_0) is not squeezed and clipped by the row.
* Studio: fold fine-tuned models into On Device tab
Remove the Hub models and Fine-tuned source tabs. Fine-tuned models now
show as a section in the Hub tab's On Device view, above Custom Folders,
with the Train icon and a collapse toggle. The section only appears when
the user has fine-tuned models. With no external providers the lone Hub
tab hides its own toggle.
Also: tick-circle Show hidden checkbox and drop the divider above Eject;
keep run settings load params (KV cache dtype, speculative, tensor
parallel) from being clobbered by a mid-load status poll.
* Studio: stage load settings in the sidebar with a Load on selection toggle
Replace the pre-load settings popup with a staging flow in the Run settings
sidebar. The gear on a downloaded quant row now stages the model and opens
Run settings with Load model and Cancel buttons, so options like context
length, KV cache, speculative decoding and tensor parallelism are set before
the model loads. A "Remember these settings" tick reuses them next time.
Add a global Load on selection toggle in Settings, Chat tab (default on).
On: Unsloth auto-picks the best settings for your hardware and loads on
selection. Off: picking a model stages it in Run settings to customize first.
The gear always stages, regardless of the toggle.
Other polish in this change:
- Fine-tuned models live under the On Device tab, with a train icon on the
header that jumps to the Fine-tuned section.
- Default to the On Device tab when downloads exist, otherwise the last used
section.
- Standard Unsloth tooltips on the train, folder and gear icons.
- Request the gguf param count on every Hub listing fetch so Kimi, MiniMax
and GLM show a size badge.
- Search Hub hover state, scrollbar position and minor spacing fixes.
Remove the old inference load settings dialog.
* Studio: always show the fine-tuned shortcut and smooth out the picker
- Fine-tuned section and its train shortcut now always show on On Device,
with an empty state when no fine-tuned models exist yet.
- Folder icon on the header jumps to Custom Folders instead of opening the
browse popup, matching the train shortcut.
- Folder browser keeps the list mounted and dims it while refetching, so
toggling Show hidden or changing folders no longer flashes.
- Drop the tooltip hover grace area in the picker so moving between the
train, folder and gear icons switches the tooltip at once.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: add quantization display options and drop the fine-tuned empty text
- Settings, Chat: 'Expand quantizations' toggle. On expands every On Device
GGUF model's quantizations by default; off keeps them behind a click
(default).
- Settings, Chat: 'Show all quantizations' toggle. On lists every quant
including ones not downloaded (default); off shows downloaded only.
- Remove the empty-state line under the Fine-tuned header; the header still
shows on its own.
* Studio: let expanded quantizations collapse on click and split the On/Off help
- With Expand quantizations on, clicking an On Device model now collapses or
re-expands its quantizations. The collapse state is in memory only, so it
resets on reload and when the setting is toggled.
- Put the Off sentence on its own line in the quantization setting descriptions.
* Studio: reorder chat settings and rename the model section
- Rename the Models section to Select model settings and move it above the
Chat menu section.
- Trim the section and Load on selection descriptions.
* Studio: tighten the On/Off lines in the model setting descriptions
Use a line break instead of separate spans so the On and Off lines sit on
consecutive lines without the extra paragraph gap.
* Studio: top-align the Load on selection toggle
Add an alignTop option to SettingsRow and use it so the toggle sits at the top
of the row next to the label, not centered against the tall description.
* Studio: put the gear hint and example chip on one line
Move the gear example chip inline with its label so it reads as a single line
instead of wrapping onto its own row.
* Studio: move the New badge from API keys to Chat settings
Add the New badge to the Chat settings tab and drop it from API keys.
* Studio: line the Load on selection toggle up with the first description line
Offset the top-aligned control past the label row so it sits next to the On
line instead of the label.
* Studio: label the chat menu item Chat with Files (RAG)
Rename the Chat with Files entry in the chat menu settings to clarify it is RAG.
* Studio: drop the pill around the gear example so it fits on one line
Remove the background and padding from the gear example chip so it sits inline
with its label at a lower height.
* Studio: fold the gear example into the description line spacing
Render the gear example inline in the same text block so its line spacing
matches the On and Off lines instead of an extra flex gap.
* Studio: scope Show all quantizations to On Device only
Gate the downloaded-only filter on an onDevice flag so Recommended and other
browse lists always show every quant, and note On Device in the setting copy.
* Studio: tidy On Device GGUF rows
- Drop the redundant Quantizations subheading under On Device models.
- Relay GGUF vision support up to the model name as a Vision badge instead.
- Drop the repo size from On Device GGUF model rows since the quants already
show their size.
* Studio: pin the eject button and tidy General settings
- Move Eject loaded model out of the scrollable list into a centered footer so
it stays in view no matter how far the list is scrolled.
- Space out and center the gear example in the Load on selection description.
- General: drop the duplicate Unsloth version section, move llama.cpp
notifications above Helper LLM, and note new models in its description.
* Studio: add left padding before the gear example
Nudge the gear example away from its label with a small left margin.
* Studio: make the eject footer a sticky bar over the list
Pin Eject loaded model to the bottom of the scroll area with the menu
background so rows scroll under it, and drop the divider line.
* Studio: drop the eject footer background, keep it a sticky button
Make the sticky eject a centered transparent button so it coexists with the
rows scrolling behind it. The wrapper ignores pointer events so only the button
is clickable.
* Studio: give the eject button a solid background
Add the menu background, a border and a soft shadow to the sticky eject button
so it reads as a floating button over the list.
* Studio: restore the eject footer block, keep hover on the button only
Bring back the full-width menu background behind the sticky eject footer, but
keep the button compact and centered so the hover stays on the button.
* Studio: show the vision badge on On Device rows without expanding
- cached-gguf listing reports has_vision (mmproj present), so the badge shows
on the model name without opening the quantizations.
- Make the vision badge icon-only with a tooltip: "This model can process
image inputs". Falls back to the expander-reported value on older backends.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Make LM Studio and Local models sections collapsible
* Fade the eject footer instead of a solid block
* Wrap the vision badge in a bordered pill
* Taller model list with the eject footer pinned to the bottom
* Use purple for the vision badge to set it apart from GGUF
* Reduce the model list height
* Make the eject button inline with no background block
* Match the vision badge color to the Hub indigo tone
* Shorten the model list and square off the format tags
* Pin the eject button so it floats at the bottom of the list
* Give the floating eject button a tinted background
* Add bottom clearance so the list ends on white space under the eject button
* Match eject button to the menu background and unify the settings gear icon
* Move eject below the list and match its shadow and dark background
* Drop the min height so short model lists leave no white space
* Remove the eject button fill so it never covers the list
* Nest dropdown hover radius inside the menu corners
* Float the eject pill again and fix sort dropdown hover radius
* Make the eject button opaque in both themes on hover and dark
* Trim the model menu bottom padding so it stops clipping the last row
* Match dark eject background to the Search Hub button and pad row indicators
* Fade the model list bottom edge while rows sit below the fold
* Lift the eject button and trim the section toggle right padding
* Nudge the model list taller and run the bottom fade to the box edge
* Nudge the model list slightly taller
* Remove the eject button shadow
* Align the eject button to the right
* Widen the Search Hub and dropdowns and right-align them
* Seat the eject button at the base and restore On Device right padding
* Reduce the Search Hub and dropdown width by 4px
* Widen the model menu so the section toggle keeps its padding
* Make the eject button an icon-only button with shadow
* Tighten section tab padding to cut the grey between tabs
* Revert section tab padding back to px-3
* Remove the section toggle trailing padding
* Add an eject button beside the model selector trigger
* Shrink the in-list eject button to a smaller proportional size
* Raise the in-list eject button
* Make the trigger eject a bare icon next to the dropdown arrow
* Revert eject back to the labeled button on the right
* Place the format and sort dropdowns next to the section toggle
* Raise the eject button and shorten its label to Eject model
* Widen the gap between the toggle and dropdowns slightly
* Align Search Hub with the last dropdown via a shared-width grid
* Narrow the model menu for symmetric padding
* Stretch the search row so Search Hub lines up with the last dropdown
* Inset the list so the right padding matches the left
* Right-align dropdowns and full-width search so Search Hub meets the last dropdown
* Pack section toggle and dropdowns with a uniform gap
* Inset search row so Search Hub aligns with the Trending dropdown
* Trim model menu right padding to match the left
* Nudge model list scrollbar inward
* Move eject button to the bottom left with a light shadow
* Shorten show all quantizations description
* Keep eject button right-aligned, nudged in from the edge
* Move Connected into the section toggle as a cloud-icon tab
* Align eject button with the format tag edge
* Right-align Connected layout so Search Hub meets Trending
* Download selected models through the Hub download manager
* Add Other models section for non-Unsloth downloads
* Add directions icon and shortcut for Other models section
* Space out subheadings and gate Other models on non-Unsloth downloads
* Use direction-right icon for Other models
* Use flag icon for Other models
* Widen Connected menu so dropdowns align with Search Hub
* Model selector: truncate long quant labels and tidy layout
- Hub GGUF card: truncate long file-path quant labels with an ellipsis
instead of overflowing the row.
- Connected layout: left-pack the dropdowns and size the box so the last
dropdown's right gap matches the pill's left gap, with Search Hub on its edge.
- On Device: show MLX/Safetensors with the size on non-GGUF rows.
- Connected list rows use the same grey hover as the tabs; the selected
section tab no longer shows a hover change.
* Model selector: drop stale custom section on restore
A persisted custom section value no longer maps to a tab, so restoring it
opened the picker to an empty view. Fall back to recommended instead.
* Model selector: align the non-connected search bar with the All dropdown
Nudge the non-connected box width so the search bar's right edge meets the
All dropdown, which lands Search Hub on the last dropdown's edge.
* Studio chat model selector: remember last tab, route non-GGUF downloads through Hub, stack overlays
- Restore the last Hub section (Recommended / On Device) on every open instead of always snapping to On Device when downloads exist.
- Route uncached non-GGUF repos (safetensors / MLX) through the Hub download manager via a snapshot download, so every model download shows in the bottom-right indicator and follows Load on selection like GGUF.
- Allow safetensors in Recommended on Mac (they run locally there now), and honor the Safetensors format filter instead of dropping it via the recommendation default.
- Stack bottom-right overlays in one column so the download panel and banners never overlap.
- Add evenly spaced divider lines between the On Device subheadings.
- Pad the bottom of the list so the floating Eject pill never covers the last row.
* Studio downloads panel: widen left padding on header and rows
Bump the left inset to pl-4 while keeping pr-3 so the collapse and cancel buttons stay put.
* Studio: update cached-gguf route tests for the has_vision field
list_cached_gguf now returns has_vision per row (vision badge on On Device);
the expected dicts were missing it. True for the mmproj vision repo, False elsewhere.
* Studio: keep MLX/safetensors selectable in chat-only Mac search
The empty Recommended view allows GGUF plus MLX/safetensors on Mac, but the
curated and HF search lists dropped non-GGUF in chat-only via a GGUF-only filter,
so typing a query hid runnable Mac models. Reuse isRecommendableFormat in both
lists so search matches the empty view (chat-only non-Mac stays GGUF-only).
* Model selector: restore global model search and fix GGUF/device-fit regressions
- Search: training, export and onboarding pickers searched only the unsloth org
on a typed query. Restore the prior behavior (global Hub search with unsloth
floated first when a query is typed, curated unsloth listing when empty).
- Recommended browse: the GGUF/MLX-only gate ran before the format filter, so
the Safetensors filter and the Trending/Recent sorts always came back empty.
Apply that gate only for the Recommended sort and chat-only mode.
- GGUF metadata: request the gguf expand field through listModels so repos with
no size token in the name (Kimi, MiniMax, GLM) report a param count for the
size and OOM badge.
- Local GGUF: custom-folder and standalone ./models/*.gguf files now load
directly with the GGUF marker instead of dead-ending in the variant expander,
and scanned GGUF folders are classified via a backend model_format hint.
- Device fit: use system RAM in the budget on unified-memory hosts, and keep MLX
rows selectable on chat-only Macs.
- kv-cache-estimate: resolve the quant from the snapshot-relative path, skip MTP
drafter files, and prefer the most complete snapshot (mirrors the variant
scanner). Bound the Ollama manifest walk.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Model selector: classify suffixless local GGUF folders consistently
Complete the model_format plumbing so a GGUF folder is detected and loaded
through the same GGUF path that the format filter already uses:
- _scan_models_dir: a config.json no longer disqualifies a folder whose only
weights are .gguf, so HF GGUF repos shipping a config still classify as GGUF.
- _scan_lmstudio_dir: emit model_format for every GGUF row (LM Studio dirs
rarely carry a -GGUF suffix), via a shared _dir_model_format helper.
- Custom Folders and LM Studio rows: use localModelIsGguf (the same helper the
filter uses) so the row label, expand-vs-direct-load, and isGguf flag agree;
a suffixless GGUF folder no longer filters as GGUF but loads as non-GGUF.
Adds tests/test_local_model_format.py covering the classification rule.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio model selector: tighten section spacing
Trim each subheading's gap to its rows (pb-1.5 to pb-1) and pull the On Device
heading block tight to the controls while Recommended keeps a little top room.
* Hub: format filter fix, sort defaults, avatar and layout polish
- Format dropdown now filters the feed's Latest list too, so the default
GGUF hides fp8/safetensors and picking a format changes the rows.
- Latest Unsloth Models sorts by newest created, not recently updated.
- Sort dropdown order: Newest, Trending, Most downloads, Recently
updated, Most likes.
- Unsloth uploads with no upstream provider logo show the Unsloth avatar
instead of a colored initial.
- Owner scope pill gets a little more room before the chevron.
- README detail column lines up with the top bar (both-edges gutter).
- Long file-path quant labels truncate instead of overflowing the row.
- Model list keyboard nav no longer clips the focus ring.
- Run settings sheet: restore the Remember settings toggle and larger
Load/Cancel buttons on the staged load flow.
* Hub: hide the RAG embedding model from browse previews
The Hub discover feed and chat model selector pull from the Hugging Face
listing on the client, which the backend _is_hidden_model filter never
touches, so the RAG embedder (unsloth/bge-small-en-v1.5-GGUF) and the
llama.cpp validation probe leaked into the lists.
Added isHiddenModelId mirroring the backend needles and filtered it out of
the discover rows, the trending feed, and the selector's recommended and
Hugging Face search lists. Per-repo file and download views are untouched,
so the model is never deleted and a reinstall still shows it as already
downloaded.
* Studio: skip hidden dirs when checking a folder for downloaded models
_dir_has_downloaded_model walked the tree with rglob("*") bounded by
max_entries. rglob yields entries in arbitrary order and counts every one, so a
model directory that also holds a large hidden subtree (.git/.cache/venv) could
exhaust the budget before reaching the real weights and falsely report no model,
hiding a valid Recommended-folder chip. Replace the generic-weights pass with a
bounded BFS that skips hidden directories so their entries can't starve the walk.
Adds a regression test (50-entry .git beside the weights, max_entries=10).
* Fix/adjust model selector handling for PR #6364
* Studio: address codex review on the staging/recommended-folder paths
- chat-page auto-load: selectModel only clears pendingSelection on success, so a
failed auto-load left the hidden stage (and its edited load knobs) behind.
Abandon the stage when it still matches the failed pick.
- model picker: count fine-tuned rows in the On Device empty check so a
fine-tuned-only tab no longer shows a false 'No models on device' message
above the Fine-tuned section.
- general settings: add the remembered per-model load settings key to PREFS_KEYS
so 'Reset all local preferences' actually clears it.
- recommended-folders: recognize PyTorch .bin weights (gated by the scanner's
weight-name prefixes) so a .bin-only model folder still earns a chip; add tests.
* Studio: name-gate .bin weight detection and complete selector preference reset
Follow-up to the codex review on the model_format/recommended-folder paths:
- _dir_model_format and _scan_models_dir treated any .bin (incl. tokenizer.bin)
as a non-GGUF weight, so a suffixless GGUF folder shipping a companion .bin was
misclassified as a plain checkpoint and routed through the wrong load path.
Factor the scanner's weight-name gating into shared _is_weight_bin /
_has_non_gguf_weights helpers and use them everywhere (also in
_dir_has_downloaded_model).
- PREFS_KEYS was missing the new 'Select model settings' keys (load on selection,
expand/show-all quantizations), so 'Reset all local preferences' left them set.
- On Device cached search dropped the active format filter while a query was
typed; keep matchesFormatFilter applied so the format dropdown stays consistent.
Adds tests for the tokenizer.bin vs weight-.bin classification.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: validate Ollama blobs, gate staged context, honor RAM budget on no-GPU hosts
- recommended-folders: only count an Ollama dir once its manifest resolves to an
on-disk model blob, so a failed/pruned pull no longer surfaces an empty chip
- GGUF variant click: only seed the staged contextLength for already-downloaded
picks, so choosing an undownloaded quant from a partially cached repo still
starts its download (the staging effect short-circuits on a known context)
- device fit: classify GGUF variants against the system-RAM budget on no-GPU /
unified-memory hosts instead of reporting everything as fits, and pass
systemRamGb to every variant expander regardless of gpu.available
* Studio: scope Hub search to Recommended, fix staged non-GGUF settings, keep local MLX on Mac
- model picker: only run the Hub search hooks on the Recommended section. On
Device / Connected render local data, so typing there no longer fires HF
requests or a spinner and the local/offline flow is preserved
- chat settings: when a pick is staged, decide the GGUF-only controls from the
staged model's type, not the currently loaded model's. A staged non-GGUF Hub
repo no longer inherits a loaded GGUF's context/KV/speculative controls
- On Device: keep local MLX builds in ./models selectable on Mac (chat-only ran
GGUF/MLX only, but the filter dropped MLX before the format toggle)
---------
Co-authored-by: shimmyshimmer
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han
Co-authored-by: wasimysaid
---
studio/backend/models/models.py | 9 +
studio/backend/routes/models.py | 325 +-
.../backend/tests/test_cached_gguf_routes.py | 9 +
.../backend/tests/test_local_model_format.py | 117 +
.../test_recommended_folders_has_model.py | 166 +
.../backend/tests/test_resolve_quant_gguf.py | 98 +
studio/frontend/src/app/provider.tsx | 25 +-
.../frontend/src/components/app-sidebar.tsx | 6 +-
.../assistant-ui/model-selector.tsx | 383 +-
.../model-selector/folder-browser.tsx | 102 +-
.../model-selector/model-capabilities.ts | 74 +
.../model-load-settings-action.tsx | 59 +
.../model-selector/model-usage.ts | 46 +
.../assistant-ui/model-selector/pickers.tsx | 3669 ++++++++++++-----
.../assistant-ui/model-selector/pill-tabs.tsx | 104 +
.../model-selector/recommended-fit.ts | 116 +
.../remembered-load-settings.ts | 54 +
.../assistant-ui/model-selector/row-meta.ts | 93 +
.../model-selector/source-tabs.ts | 18 +
.../assistant-ui/model-selector/types.ts | 2 +
.../src/features/chat/api/chat-api.ts | 36 +-
.../frontend/src/features/chat/chat-page.tsx | 176 +-
.../src/features/chat/chat-settings-sheet.tsx | 78 +-
.../chat/hooks/use-chat-model-runtime.ts | 9 +-
.../hooks/use-staged-model-preparation.ts | 53 +-
.../lib/apply-inference-status-to-store.ts | 21 +-
.../src/features/chat/projects-page.tsx | 11 +-
.../chat/stores/chat-runtime-store.ts | 90 +-
.../frontend/src/features/chat/types/api.ts | 2 +
.../src/features/export/export-page.tsx | 12 +-
.../src/features/hub/catalog/dot-tag.tsx | 10 +-
.../hub/catalog/gguf-download-card.tsx | 12 +-
.../features/hub/catalog/hub-detail-view.tsx | 12 +-
.../features/hub/catalog/hub-option-menu.tsx | 4 +-
.../features/hub/catalog/models-toolbar.tsx | 4 +-
.../src/features/hub/catalog/owner-avatar.tsx | 30 +
.../hub/catalog/owner-scope-toggle.tsx | 3 +-
.../download-manager-panel.tsx | 17 +-
.../hub/hooks/use-hub-model-search.ts | 76 +-
studio/frontend/src/features/hub/hub-page.tsx | 13 +-
studio/frontend/src/features/hub/hub.css | 8 +
.../frontend/src/features/hub/lib/channels.ts | 4 +-
.../src/features/hub/lib/hidden-models.ts | 24 +
.../components/steps/dataset-step.tsx | 21 +-
.../components/steps/model-selection-step.tsx | 19 +-
.../components/shared/hf-dataset-combobox.tsx | 5 +-
.../settings/components/settings-row.tsx | 28 +-
.../src/features/settings/settings-dialog.tsx | 16 +-
.../src/features/settings/tabs/chat-tab.tsx | 273 +-
.../features/settings/tabs/general-tab.tsx | 69 +-
.../studio/sections/dataset-section.tsx | 22 +-
.../studio/sections/model-section.tsx | 19 +-
studio/frontend/src/hooks/index.ts | 4 -
studio/frontend/src/hooks/use-gpu-info.ts | 11 +-
.../src/hooks/use-hf-dataset-search.ts | 398 --
.../frontend/src/hooks/use-hf-model-search.ts | 348 --
.../src/hooks/use-hf-paginated-search.ts | 134 -
.../frontend/src/hooks/use-infinite-scroll.ts | 28 -
.../src/hooks/use-recommended-model-vram.ts | 63 -
studio/frontend/src/i18n/locales/en.ts | 29 +-
studio/frontend/src/index.css | 36 +-
studio/frontend/src/lib/hf-cache.ts | 162 -
62 files changed, 5032 insertions(+), 2833 deletions(-)
create mode 100644 studio/backend/tests/test_local_model_format.py
create mode 100644 studio/backend/tests/test_recommended_folders_has_model.py
create mode 100644 studio/backend/tests/test_resolve_quant_gguf.py
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/model-capabilities.ts
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/row-meta.ts
create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/source-tabs.ts
create mode 100644 studio/frontend/src/features/hub/lib/hidden-models.ts
delete mode 100644 studio/frontend/src/hooks/use-hf-dataset-search.ts
delete mode 100644 studio/frontend/src/hooks/use-hf-model-search.ts
delete mode 100644 studio/frontend/src/hooks/use-hf-paginated-search.ts
delete mode 100644 studio/frontend/src/hooks/use-infinite-scroll.ts
delete mode 100644 studio/frontend/src/hooks/use-recommended-model-vram.ts
delete mode 100644 studio/frontend/src/lib/hf-cache.ts
diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py
index d1ef368eae..20dea5ec12 100644
--- a/studio/backend/models/models.py
+++ b/studio/backend/models/models.py
@@ -154,6 +154,10 @@ class GgufVariantsResponse(BaseModel):
default_variant: Optional[str] = Field(
None, description = "Recommended default quantization variant"
)
+ context_length: Optional[int] = Field(
+ None,
+ description = "Native max context from GGUF metadata; set once a variant is downloaded",
+ )
class LocalModelInfo(BaseModel):
@@ -170,6 +174,11 @@ class LocalModelInfo(BaseModel):
None,
description = "HF repo id for cached models, e.g. org/model",
)
+ model_format: Optional[str] = Field(
+ None,
+ description = "Detected weights format ('gguf' when known). Lets the UI "
+ "classify scanned folders whose name lacks a -GGUF suffix.",
+ )
updated_at: Optional[float] = Field(
None,
description = "Unix timestamp of latest observed update",
diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py
index 1e567774ac..14fb5e234c 100644
--- a/studio/backend/routes/models.py
+++ b/studio/backend/routes/models.py
@@ -225,6 +225,29 @@ def _is_model_directory(d: Path) -> bool:
return False
+# Weight ``.bin`` files the local scanners accept (PyTorch checkpoints), as
+# opposed to companion ``.bin`` files like ``tokenizer.bin``. Mirrors the gating
+# in ``_is_weight_file`` so every weight check classifies the same files.
+_WEIGHT_BIN_PREFIXES = ("pytorch_model", "model", "adapter_model", "consolidated")
+
+
+def _is_weight_bin(name: str) -> bool:
+ low = name.lower()
+ return low.endswith(".bin") and low.startswith(_WEIGHT_BIN_PREFIXES)
+
+
+def _has_non_gguf_weights(path: Path) -> bool:
+ """True if *path* holds non-GGUF weight files (``.safetensors`` or a weight
+ ``.bin``), ignoring companion ``.bin`` files such as ``tokenizer.bin`` so a
+ GGUF-only folder is not misread as a plain checkpoint."""
+ try:
+ if any(path.glob("*.safetensors")):
+ return True
+ return any(_is_weight_bin(f.name) for f in path.glob("*.bin"))
+ except OSError:
+ return False
+
+
def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[LocalModelInfo]:
if not models_dir.exists() or not models_dir.is_dir():
return []
@@ -242,6 +265,7 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
display_name = models_dir.name,
path = str(models_dir),
source = "models_dir",
+ model_format = _dir_model_format(models_dir),
updated_at = updated_at,
),
]
@@ -253,13 +277,12 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
try:
if not child.is_dir():
continue
- has_model_files = (
- (child / "config.json").exists()
- or (child / "adapter_config.json").exists()
- or any(child.glob("*.safetensors"))
- or any(child.glob("*.bin"))
- or any(child.glob("*.gguf"))
- )
+ has_gguf = any(child.glob("*.gguf"))
+ has_non_gguf_weights = _has_non_gguf_weights(child)
+ has_config = (child / "config.json").exists() or (
+ child / "adapter_config.json"
+ ).exists()
+ has_model_files = has_gguf or has_non_gguf_weights or has_config
except OSError:
# Skip unreadable children rather than failing the scan.
continue
@@ -269,12 +292,17 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
+ # 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
found.append(
LocalModelInfo(
id = str(child),
display_name = child.name,
path = str(child),
source = "models_dir",
+ model_format = model_format,
updated_at = updated_at,
),
)
@@ -294,6 +322,7 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
display_name = gguf_file.stem,
path = str(gguf_file),
source = "models_dir",
+ model_format = "gguf",
updated_at = updated_at,
),
)
@@ -333,6 +362,21 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
return found
+def _dir_model_format(path: Path) -> 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.
+ """
+ try:
+ if not any(path.glob("*.gguf")):
+ return None
+ return None if _has_non_gguf_weights(path) else "gguf"
+ except OSError:
+ return None
+
+
def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
"""Scan an LM Studio models directory for model files.
@@ -355,6 +399,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
display_name = lm_dir.name,
path = str(lm_dir),
source = "lmstudio",
+ model_format = _dir_model_format(lm_dir),
updated_at = updated_at,
),
]
@@ -374,6 +419,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
display_name = child.stem,
path = str(child),
source = "lmstudio",
+ model_format = "gguf",
updated_at = updated_at,
),
)
@@ -392,6 +438,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
display_name = child.name,
path = str(child),
source = "lmstudio",
+ model_format = _dir_model_format(child),
updated_at = updated_at,
),
)
@@ -420,6 +467,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
display_name = model_dir.name,
path = str(model_dir),
source = "lmstudio",
+ model_format = _dir_model_format(model_dir),
updated_at = updated_at,
),
)
@@ -435,6 +483,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
display_name = model_dir.stem,
path = str(model_dir),
source = "lmstudio",
+ model_format = "gguf",
updated_at = updated_at,
),
)
@@ -847,13 +896,88 @@ async def remove_scan_folder_endpoint(
return {"ok": True}
+def _dir_has_downloaded_model(directory: Path, max_entries: int = 4000) -> bool:
+ """True if *directory* actually holds a downloaded model.
+
+ Recommended-folder chips should only appear once the well-known dir
+ has real weights, not just an empty LM Studio/Ollama scaffold. Two
+ layouts: a GGUF/safetensors/PyTorch-bin weight file anywhere in the
+ tree (LM Studio, plain dirs) or the Ollama content-addressable store
+ (a non-empty ``manifests/`` beside ``blobs/``, whose blobs carry no
+ extension). Weight detection mirrors the local scanner so a folder the
+ chip leads to is one the scanner would actually surface a model from.
+ Bounded by *max_entries* so a huge tree can't stall the request.
+ """
+ # Ollama layout: each manifest is JSON referencing content-addressable
+ # blobs. A manifest file alone is not enough -- a failed or pruned pull
+ # leaves the manifest behind with its model blob missing, so we resolve the
+ # ``application/vnd.ollama.image.model`` layer to an on-disk blob before
+ # counting it, mirroring _scan_ollama_dir (which only surfaces a model once
+ # its blob resolves). Otherwise the chip leads to an empty picker.
+ visited = 0
+ manifests = directory / "manifests"
+ blobs = directory / "blobs"
+ try:
+ if _safe_is_dir(manifests) and _safe_is_dir(blobs):
+ for m in manifests.rglob("*"):
+ visited += 1
+ if visited > max_entries:
+ break
+ if not m.is_file():
+ continue
+ try:
+ manifest = json.loads(m.read_text())
+ except (json.JSONDecodeError, OSError, ValueError):
+ continue
+ for layer in manifest.get("layers") or []:
+ if layer.get("mediaType") != "application/vnd.ollama.image.model":
+ continue
+ digest = layer.get("digest", "")
+ if digest and (blobs / digest.replace(":", "-")).is_file():
+ return True
+ except OSError:
+ pass
+ # Generic weights: any GGUF/safetensors in a bounded BFS that skips hidden
+ # directories (``.git``/``.cache``/venvs). ``rglob`` walks in arbitrary order
+ # and counts every entry, so a large hidden subtree could exhaust the budget
+ # before reaching real weights and falsely report "no model".
+ queue = [directory]
+ visited = 0
+ while queue:
+ current = queue.pop(0)
+ try:
+ entries = list(current.iterdir())
+ except OSError:
+ continue
+ for entry in entries:
+ visited += 1
+ if visited > max_entries:
+ return False
+ try:
+ if entry.is_dir():
+ if not entry.name.startswith("."):
+ queue.append(entry)
+ else:
+ low = entry.name.lower()
+ if low.endswith((".gguf", ".safetensors")):
+ return True
+ # PyTorch checkpoints the scanner also accepts; gate by name
+ # so tokenizer.bin and friends don't count as weights.
+ if _is_weight_bin(entry.name):
+ return True
+ except OSError:
+ continue
+ return False
+
+
@router.get("/recommended-folders")
async def get_recommended_folders(current_subject: str = Depends(get_current_subject)):
- """Return well-known model directories that exist on this machine.
+ """Return well-known model directories that hold a downloaded model.
Lightweight alternative to ``browse-folders`` for the frontend's
- one-click "Recommended" chips; returns existing paths only (HF
- cache, LM Studio, Ollama, ``~/models``, etc.).
+ one-click "Recommended" chips. Only paths that actually contain
+ weights are returned, so an empty LM Studio/Ollama scaffold no longer
+ shows up as a suggestion.
"""
from utils.paths.storage_roots import lmstudio_model_dirs
@@ -869,7 +993,11 @@ async def get_recommended_folders(current_subject: str = Depends(get_current_sub
return
if resolved in seen:
return
- if _safe_is_dir(resolved) and os.access(resolved, os.R_OK | os.X_OK):
+ if (
+ _safe_is_dir(resolved)
+ and os.access(resolved, os.R_OK | os.X_OK)
+ and _dir_has_downloaded_model(Path(resolved))
+ ):
seen.add(resolved)
folders.append(resolved)
@@ -2270,6 +2398,170 @@ async def check_embedding_model(
)
+def _read_native_context_length(repo_id: str, is_local: bool) -> Optional[int]:
+ """Native max context from a downloaded GGUF for this repo, or None.
+
+ The value is identical across quants, so reading one non-mmproj shard's
+ header is enough. Only resolves once a file is on disk. Never raises.
+ """
+ try:
+ from utils.models.gguf_metadata import read_gguf_context_length
+ if is_local:
+ roots = [Path(repo_id)]
+ else:
+ from huggingface_hub import constants as hf_constants
+
+ if not _is_valid_repo_id(repo_id):
+ return None
+ cache_dir = Path(hf_constants.HF_HUB_CACHE)
+ target = f"models--{repo_id.replace('/', '--')}".lower()
+ roots = [e for e in cache_dir.iterdir() if e.name.lower() == target]
+
+ for root in roots:
+ for f in _iter_gguf_paths(root):
+ if _is_mmproj_filename(f.name):
+ continue
+ n = read_gguf_context_length(str(f))
+ if n:
+ return n
+ except Exception:
+ pass
+ return None
+
+
+def _resolve_quant_gguf(repo_id: str, quant: str, is_local: bool) -> tuple[Optional[str], int]:
+ """Primary shard path and total weight bytes for a downloaded quant, or
+ (None, 0). Metadata lives in shard 1, so the lexicographically first file of
+ the matching quant is returned. Scoped to one snapshot to avoid summing the
+ same quant across revisions; when several snapshots hold the quant the most
+ complete one (largest total) wins so a partial revision can't shadow it.
+ Mirrors list_local_gguf_variants: quant labels are read from the snapshot-
+ relative path (so layouts like ``BF16/model.gguf`` resolve) and MTP drafter
+ files are skipped (so a ``...-Q8_0-MTP.gguf`` drafter can't be picked as the
+ Q8_0 weights). Never raises.
+ """
+ try:
+ from utils.models.model_config import (
+ _extract_quant_label,
+ _is_big_endian_gguf_path,
+ _is_mtp_drafter,
+ )
+
+ if is_local:
+ roots = [Path(repo_id)]
+ else:
+ from huggingface_hub import constants as hf_constants
+
+ if not _is_valid_repo_id(repo_id):
+ return None, 0
+ cache_dir = Path(hf_constants.HF_HUB_CACHE)
+ target = f"models--{repo_id.replace('/', '--')}".lower()
+ roots = []
+ for entry in cache_dir.iterdir():
+ if entry.name.lower() == target:
+ snaps = entry / "snapshots"
+ if snaps.is_dir():
+ roots.extend(s for s in snaps.iterdir() if s.is_dir())
+
+ want = quant.lower().replace("-", "").replace("_", "")
+ best_total = 0
+ best_first: Optional[str] = None
+ for root in roots:
+ matches: list[tuple[str, Path]] = []
+ total = 0
+ for f in _iter_gguf_paths(root):
+ if _is_mmproj_filename(f.name):
+ continue
+ try:
+ rel = f.relative_to(root).as_posix()
+ except ValueError:
+ rel = f.name
+ if _is_mtp_drafter(rel):
+ continue
+ q = _extract_quant_label(rel)
+ if _is_big_endian_gguf_path(rel, q):
+ continue
+ if q.lower().replace("-", "").replace("_", "") != want:
+ continue
+ try:
+ total += f.stat().st_size
+ except OSError:
+ continue
+ matches.append((rel, f))
+ # Prefer the most complete snapshot so a partial older revision can't
+ # shadow a newer complete one and underestimate the weight bytes.
+ if matches and total > best_total:
+ matches.sort(key = lambda m: m[0])
+ best_total = total
+ best_first = str(matches[0][1])
+ if best_first is not None:
+ return best_first, best_total
+ except Exception:
+ pass
+ return None, 0
+
+
+@router.get("/kv-cache-estimate")
+async def get_kv_cache_estimate(
+ repo_id: str = Query(..., description = "HF repo ID or local path"),
+ quant: str = Query(..., description = "Quantization label (e.g. Q4_K_M)"),
+ n_ctx: int = Query(..., ge = 1, description = "Context length to size the KV cache for"),
+ cache_type_kv: Optional[str] = Query(None, description = "KV cache dtype (e.g. q8_0)"),
+ current_subject: str = Depends(get_current_subject),
+):
+ """Estimate KV cache + weight bytes for a downloaded GGUF at n_ctx.
+
+ Powers the load dialog's "exceeds memory" warning using the same
+ architecture-aware estimator as load. Best-effort: returns nulls when the
+ metadata is unavailable so the UI simply shows no warning.
+ """
+ null = {"kv_bytes": None, "weights_bytes": None, "native_context": None}
+ try:
+ from utils.models.model_config import is_local_path
+
+ is_local = is_local_path(repo_id)
+ path, weights_bytes = _resolve_quant_gguf(repo_id, quant, is_local)
+ if not path:
+ return null
+
+ from core.inference.llama_cpp import LlamaCppBackend
+
+ be = LlamaCppBackend.__new__(LlamaCppBackend)
+ for attr in (
+ "_context_length",
+ "_n_layers",
+ "_n_kv_heads",
+ "_n_heads",
+ "_embedding_length",
+ "_kv_key_length",
+ "_kv_value_length",
+ "_kv_lora_rank",
+ "_sliding_window",
+ "_sliding_window_pattern",
+ "_ssm_inner_size",
+ "_full_attention_interval",
+ "_key_length_mla",
+ "_n_kv_heads_by_layer",
+ "_kv_key_length_swa",
+ "_kv_value_length_swa",
+ "_shared_kv_layers",
+ "_nextn_predict_layers",
+ ):
+ setattr(be, attr, None)
+ be._model_identifier = "kv-estimate"
+ be._read_gguf_metadata(path)
+
+ kv = be._estimate_kv_cache_bytes(n_ctx, cache_type_kv)
+ return {
+ "kv_bytes": int(kv) if kv else None,
+ "weights_bytes": weights_bytes or None,
+ "native_context": be._context_length,
+ }
+ except Exception as e:
+ logger.debug(f"kv-cache-estimate failed for '{repo_id}' {quant}: {e}")
+ return null
+
+
@router.get("/gguf-variants", response_model = GgufVariantsResponse)
async def get_gguf_variants(
repo_id: str = Query(
@@ -2307,6 +2599,7 @@ async def get_gguf_variants(
],
has_vision = has_vision,
default_variant = default_variant,
+ context_length = _read_native_context_length(repo_id, is_local = True),
)
# Remote HuggingFace repo — query HF API.
@@ -2375,6 +2668,7 @@ async def get_gguf_variants(
],
has_vision = has_vision,
default_variant = default_variant,
+ context_length = _read_native_context_length(repo_id, is_local = False),
)
except Exception as e:
@@ -2673,6 +2967,14 @@ def _is_main_gguf_filename(name: str) -> bool:
return _is_gguf_filename(name) and not _is_mmproj_filename(name)
+def _repo_has_mmproj(repo_info) -> bool:
+ """True if the repo ships a GGUF vision adapter (mmproj), so it can
+ take image inputs. Cheap: scans already-listed file names only."""
+ return any(
+ _is_mmproj_filename(f.file_name) for revision in repo_info.revisions for f in revision.files
+ )
+
+
def _iter_gguf_paths(root: Path):
for path in root.rglob("*"):
if path.is_file() and _is_gguf_filename(path.name):
@@ -2770,6 +3072,7 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
"repo_id": repo_id,
"size_bytes": total_size,
"cache_path": str(repo_info.repo_path),
+ "has_vision": _repo_has_mmproj(repo_info),
}
# Keep the newest timestamp across duplicate caches;
# attach only when known so absent rows sort as oldest.
diff --git a/studio/backend/tests/test_cached_gguf_routes.py b/studio/backend/tests/test_cached_gguf_routes.py
index 03662e7b08..b2ead305ba 100644
--- a/studio/backend/tests/test_cached_gguf_routes.py
+++ b/studio/backend/tests/test_cached_gguf_routes.py
@@ -82,6 +82,7 @@ def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monk
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
+ "has_vision": False,
}
]
@@ -103,6 +104,7 @@ def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_
"repo_id": "Org/Model-Without-Suffix",
"size_bytes": 7_000,
"cache_path": str(repo.repo_path),
+ "has_vision": False,
}
]
@@ -197,6 +199,7 @@ def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(monkeypatch,
"repo_id": "org/dupe",
"size_bytes": 6_000,
"cache_path": str(larger.repo_path),
+ "has_vision": False,
}
]
@@ -226,6 +229,7 @@ def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp
"repo_id": "Org/SharedBlobRepo",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
+ "has_vision": False,
}
]
@@ -275,6 +279,7 @@ def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypa
"repo_id": "Org/MixedRepo",
"size_bytes": 5_000,
"cache_path": str(mixed.repo_path),
+ "has_vision": False,
}
]
@@ -301,6 +306,7 @@ def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
"repo_id": "Org/PartialDownload",
"size_bytes": 5_000,
"cache_path": str(partial.repo_path),
+ "has_vision": False,
}
]
@@ -336,6 +342,7 @@ def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypat
"repo_id": "Org/Healthy",
"size_bytes": 5_000,
"cache_path": str(healthy.repo_path),
+ "has_vision": False,
}
]
@@ -411,6 +418,7 @@ def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeyp
"repo_id": "Org/VisionGguf",
"size_bytes": 5_000,
"cache_path": str(vision_repo.repo_path),
+ "has_vision": True,
}
]
@@ -464,6 +472,7 @@ def test_all_hf_cache_scans_survives_inaccessible_aux_cache(monkeypatch, tmp_pat
"repo_id": "Org/Active",
"size_bytes": 5_000,
"cache_path": str(tmp_path / "active"),
+ "has_vision": False,
}
]
diff --git a/studio/backend/tests/test_local_model_format.py b/studio/backend/tests/test_local_model_format.py
new file mode 100644
index 0000000000..b569163cc8
--- /dev/null
+++ b/studio/backend/tests/test_local_model_format.py
@@ -0,0 +1,117 @@
+# 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_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"
diff --git a/studio/backend/tests/test_recommended_folders_has_model.py b/studio/backend/tests/test_recommended_folders_has_model.py
new file mode 100644
index 0000000000..647d5dd3db
--- /dev/null
+++ b/studio/backend/tests/test_recommended_folders_has_model.py
@@ -0,0 +1,166 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""
+Regression test for /recommended-folders suggesting empty scaffolds.
+
+The endpoint used to surface any well-known dir that merely existed, so a
+freshly installed LM Studio or Ollama (empty ``models`` dir) showed up as a
+"Recommended" chip with no models behind it. ``_dir_has_downloaded_model``
+now gates each candidate on real weights: a GGUF/safetensors file anywhere in
+the tree, or a non-empty Ollama ``manifests/`` beside ``blobs/``.
+
+``routes.models`` pulls the full backend dep tree, so we extract the real
+helper (and its ``_safe_is_dir`` dependency) from the source via AST and run
+the shipped code in isolation, mirroring
+``test_recommended_folders_permission.py``.
+
+Run:
+ python -m pytest studio/backend/tests/test_recommended_folders_has_model.py -v
+"""
+
+import ast
+import json
+import os
+from pathlib import Path
+
+_backend_root = Path(__file__).resolve().parent.parent
+_models_src = _backend_root / "routes" / "models.py"
+
+
+def _load_has_downloaded_model():
+ """Return the real ``_dir_has_downloaded_model`` (plus its ``_safe_is_dir``
+ and ``_is_weight_bin`` deps, and the ``_WEIGHT_BIN_PREFIXES`` constant the
+ latter reads) without importing the heavy module."""
+ tree = ast.parse(_models_src.read_text())
+ wanted = {"_safe_is_dir", "_dir_has_downloaded_model", "_is_weight_bin"}
+ body = []
+ for node in tree.body:
+ if isinstance(node, ast.FunctionDef) and node.name in wanted:
+ body.append(node)
+ elif isinstance(node, ast.Assign) and any(
+ isinstance(t, ast.Name) and t.id == "_WEIGHT_BIN_PREFIXES" for t in node.targets
+ ):
+ body.append(node)
+ got = {n.name for n in body if isinstance(n, ast.FunctionDef)}
+ assert got == wanted, f"helpers missing from source: {wanted - got}"
+ module = ast.Module(body = body, type_ignores = [])
+ ns: dict = {"Path": Path, "os": os, "json": json}
+ exec(compile(module, f"", "exec"), ns)
+ return ns["_dir_has_downloaded_model"]
+
+
+has_downloaded_model = _load_has_downloaded_model()
+
+
+def test_empty_scaffold_is_false(tmp_path):
+ empty = tmp_path / "lmstudio" / "models"
+ empty.mkdir(parents = True)
+ assert has_downloaded_model(empty) is False
+
+
+def test_lmstudio_gguf_is_true(tmp_path):
+ # models/publisher/repo/file.gguf (LM Studio's nested layout).
+ repo = tmp_path / "models" / "bartowski" / "Qwen3-4B-GGUF"
+ repo.mkdir(parents = True)
+ (repo / "q4.gguf").write_bytes(b"x")
+ assert has_downloaded_model(tmp_path / "models") is True
+
+
+def test_safetensors_is_true(tmp_path):
+ repo = tmp_path / "models" / "repo"
+ repo.mkdir(parents = True)
+ (repo / "model.safetensors").write_bytes(b"x")
+ assert has_downloaded_model(tmp_path / "models") is True
+
+
+def test_ollama_empty_scaffold_is_false(tmp_path):
+ models = tmp_path / "ollama" / "models"
+ (models / "manifests").mkdir(parents = True)
+ (models / "blobs").mkdir()
+ assert has_downloaded_model(models) is False
+
+
+def test_ollama_with_manifest_is_true(tmp_path):
+ models = tmp_path / "ollama" / "models"
+ manifest = models / "manifests" / "registry.ollama.ai" / "library" / "llama3"
+ manifest.mkdir(parents = True)
+ # A real manifest references its weights via an image.model layer; the
+ # referenced blob must exist on disk for the model to be loadable.
+ (manifest / "latest").write_text(
+ json.dumps(
+ {
+ "layers": [
+ {
+ "mediaType": "application/vnd.ollama.image.model",
+ "digest": "sha256:abc",
+ }
+ ]
+ }
+ )
+ )
+ (models / "blobs").mkdir()
+ (models / "blobs" / "sha256-abc").write_bytes(b"x")
+ assert has_downloaded_model(models) is True
+
+
+def test_ollama_manifest_without_blob_is_false(tmp_path):
+ # A failed/pruned pull leaves the manifest behind but its model blob is
+ # gone: the chip must not lead to an empty picker.
+ models = tmp_path / "ollama" / "models"
+ manifest = models / "manifests" / "registry.ollama.ai" / "library" / "llama3"
+ manifest.mkdir(parents = True)
+ (manifest / "latest").write_text(
+ json.dumps(
+ {
+ "layers": [
+ {
+ "mediaType": "application/vnd.ollama.image.model",
+ "digest": "sha256:missing",
+ }
+ ]
+ }
+ )
+ )
+ (models / "blobs").mkdir() # empty: the referenced blob never landed
+ assert has_downloaded_model(models) is False
+
+
+def test_non_model_files_is_false(tmp_path):
+ junk = tmp_path / "junk"
+ junk.mkdir()
+ (junk / "readme.txt").write_text("hi")
+ assert has_downloaded_model(junk) is False
+
+
+def test_pytorch_bin_weights_are_true(tmp_path):
+ # A folder whose only weights are PyTorch .bin checkpoints (which the local
+ # scanner accepts) should still earn a Recommended chip.
+ repo = tmp_path / "models" / "repo"
+ repo.mkdir(parents = True)
+ (repo / "config.json").write_text("{}")
+ (repo / "pytorch_model.bin").write_bytes(b"x")
+ assert has_downloaded_model(tmp_path / "models") is True
+
+
+def test_non_weight_bin_is_false(tmp_path):
+ # A stray .bin that is not a weight file (e.g. tokenizer.bin) must not count.
+ repo = tmp_path / "models" / "repo"
+ repo.mkdir(parents = True)
+ (repo / "tokenizer.bin").write_bytes(b"x")
+ assert has_downloaded_model(tmp_path / "models") is False
+
+
+def test_hidden_subtree_does_not_starve_the_budget(tmp_path):
+ # A real model dir that also holds a huge hidden subtree (e.g. a .git or
+ # .cache). The hidden entries must not exhaust max_entries before the walk
+ # reaches the actual weights, which would falsely report "no model".
+ models = tmp_path / "models"
+ git = models / ".git" / "objects"
+ git.mkdir(parents = True)
+ for i in range(50):
+ (git / f"obj{i}").write_bytes(b"x")
+ repo = models / "repo"
+ repo.mkdir()
+ (repo / "model.safetensors").write_bytes(b"x")
+ assert has_downloaded_model(models, max_entries = 10) is True
diff --git a/studio/backend/tests/test_resolve_quant_gguf.py b/studio/backend/tests/test_resolve_quant_gguf.py
new file mode 100644
index 0000000000..840c4d8d4c
--- /dev/null
+++ b/studio/backend/tests/test_resolve_quant_gguf.py
@@ -0,0 +1,98 @@
+# 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 :func:`routes.models._resolve_quant_gguf` (PR #6364 follow-up).
+
+The /kv-cache-estimate resolver must mirror list_local_gguf_variants:
+- read the quant label from the snapshot-relative path so nested layouts like
+ ``BF16/model.gguf`` resolve (not just basenames),
+- skip MTP drafter files so a ``...-Q8_0-MTP.gguf`` drafter is never returned as
+ the Q8_0 weights, and
+- when several cache snapshots hold the quant, pick the most complete (largest
+ total) so a partial older revision can't underestimate the weight bytes.
+
+No GPU/network. The resolver only stats sizes and parses file names, so the
+GGUF files can be arbitrary bytes.
+"""
+
+from __future__ import annotations
+
+import sys
+import types
+from pathlib import Path
+
+# Keep this test runnable without optional logging deps (mirrors
+# test_cached_gguf_routes.py).
+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 _write(path: Path, size: int) -> Path:
+ path.parent.mkdir(parents = True, exist_ok = True)
+ path.write_bytes(b"\0" * size)
+ return path
+
+
+def test_resolves_quant_from_parent_directory_layout(tmp_path):
+ # A repo that puts the quant label in a parent dir (BF16/model.gguf).
+ root = tmp_path / "repo"
+ f = _write(root / "BF16" / "model.gguf", 1234)
+
+ path, total = models_route._resolve_quant_gguf(str(root), "BF16", is_local = True)
+
+ assert path == str(f)
+ assert total == 1234
+
+
+def test_skips_mtp_drafter_for_main_weights(tmp_path):
+ # Main Q8_0 weights next to a same-quant MTP drafter that sorts first by name.
+ root = tmp_path / "repo"
+ main = _write(root / "model-Q8_0.gguf", 100)
+ _write(root / "MTP" / "model-Q8_0-MTP.gguf", 50)
+
+ path, total = models_route._resolve_quant_gguf(str(root), "Q8_0", is_local = True)
+
+ assert path == str(main)
+ # Drafter bytes are excluded from the weight total.
+ assert total == 100
+
+
+def test_prefers_the_complete_snapshot(tmp_path, monkeypatch):
+ from huggingface_hub import constants as hf_constants
+
+ cache = tmp_path / "hub"
+ snaps = cache / "models--org--repo" / "snapshots"
+ # Partial older snapshot: one small shard.
+ _write(snaps / "aaaa" / "model-Q4_K_M.gguf", 10)
+ # Complete newer snapshot: two larger shards.
+ complete_first = _write(snaps / "bbbb" / "model-00001-of-00002-Q4_K_M.gguf", 30)
+ _write(snaps / "bbbb" / "model-00002-of-00002-Q4_K_M.gguf", 40)
+
+ monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(cache))
+
+ path, total = models_route._resolve_quant_gguf("org/repo", "Q4_K_M", is_local = False)
+
+ # The most complete snapshot (70 bytes) wins over the partial one (10).
+ assert total == 70
+ # Shard 1 (metadata) of the complete snapshot is returned.
+ assert path == str(complete_first)
+
+
+def test_returns_none_when_quant_absent(tmp_path):
+ root = tmp_path / "repo"
+ _write(root / "model-Q4_K_M.gguf", 100)
+
+ path, total = models_route._resolve_quant_gguf(str(root), "Q8_0", is_local = True)
+
+ assert path is None
+ assert total == 0
diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx
index 29644d04cf..802f22e21e 100644
--- a/studio/frontend/src/app/provider.tsx
+++ b/studio/frontend/src/app/provider.tsx
@@ -258,7 +258,8 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return (
<>
{children}
-
+ {/* One bottom-right stack so overlays never overlap; they stack with a
+ gap, download panel anchored at the corner with banners above. */}
+
>
);
@@ -285,7 +287,6 @@ function TauriWrapper({ children }: { children: ReactNode }) {
{children}
-
>
) : (
{content}
-
+
+
+ {showApp ? : null}
+
>
);
}
@@ -326,9 +331,13 @@ function TauriWrapper({ children }: { children: ReactNode }) {
{content}
-
+
+
+ {showApp ? : null}
+