From a2423e614ae921bd0d40b2821f94ec90c3b5ed7c Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:33:17 -0700 Subject: [PATCH 01/43] Studio: hide RAG embedder from the On Device list (#6572) * Studio: hide RAG embedder from the On Device list The bge-small-en-v1.5 RAG embedder (and other infra models) were already hidden from Discover but still showed up in the On Device browse list, cluttering the user's downloaded models. They are now filtered out of On Device the same way, while a search that matches still reveals the row so the user can confirm it is already downloaded. * Studio: also check path/title when hiding infra models from On Device isHiddenModelId only saw row.id and row.repoId, but local inventory rows can have a null repoId and an id that is a hash rather than the file path/name, so the llama.cpp validation probe (stories260K.gguf) could slip into the On Device list. Pass the local row's path and title too, mirroring the backend's _is_hidden_model(m.id, m.path). Addresses review feedback from gemini-code-assist on PR #6572. * Studio: exclude infra models from On Device count and dataset list The On Device hidden-model filter was applied to datasets too, so a dataset whose id/title/path contained an infra needle (bge-small-en-v1.5, stories260k.gguf) was wrongly hidden. Bypass the filter for datasets, the same way Discover and the format filter already do. The On Device header count and the Cache/Local stat pills still used the unfiltered row counts, so a fresh install with only the bge embedder cached read 1 over an empty list. Count visible (non-infra) rows instead, keeping full counts for datasets. * Studio: count search-revealed infra rows in the On Device tally The visible-row counts excluded every hidden row unconditionally, but the On Device list reveals a hidden row when the search query matches it. So with only the bge embedder cached and a "bge" search, the list showed one row while the header and Cache stat stayed 0. Reuse isVisibleInventoryRow for the counts so a query-revealed row is counted, keeping them in step with the list. --------- Co-authored-by: Daniel Han --- studio/frontend/src/features/hub/hub-page.tsx | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/studio/frontend/src/features/hub/hub-page.tsx b/studio/frontend/src/features/hub/hub-page.tsx index 943aca5e9a..b3c9dd0dbe 100644 --- a/studio/frontend/src/features/hub/hub-page.tsx +++ b/studio/frontend/src/features/hub/hub-page.tsx @@ -740,6 +740,23 @@ export function ModelsPage() { () => (isDiscoverTab ? [] : tokenizeQuery(deferredDebouncedQuery)), [isDiscoverTab, deferredDebouncedQuery], ); + // Hide infra models (e.g. the RAG embedder bge-small-en-v1.5) from the On + // Device list like Discover, but reveal a row when a query matches it so the + // user can confirm it is already downloaded. + const isVisibleInventoryRow = useCallback( + (row: CachedInventoryRow | LocalInventoryRow) => + // Local rows can have a null repoId and an id that is a hash rather than + // the file path/name, so also check path/title (the backend's + // _is_hidden_model checks the on-disk path for the same reason). + !isHiddenModelId( + row.id, + row.repoId, + row.kind !== "cache" ? row.path : undefined, + row.kind !== "cache" ? row.title : undefined, + ) || + (inventoryTokens.length > 0 && inventoryRowMatches(row, inventoryTokens)), + [inventoryTokens], + ); // Format filter is a deliberate scope narrowing, so hard-filter it out. The // text query instead drives dim-not-filter on On Device (see ModelsCatalog) so // selection survives typing; matching rows are partitioned to the top. @@ -748,12 +765,22 @@ export function ModelsPage() { partitionByMatch( effectiveCachedRows.filter( (row) => + // Hidden-model filtering is model-only; datasets bypass it (and the + // format filter) the way Discover does, so a dataset whose + // id/title/path happens to contain an infra needle is not dropped. isDatasetMode || - matchesFormat(row.modelFormat, deferredFormatFilter), + (matchesFormat(row.modelFormat, deferredFormatFilter) && + isVisibleInventoryRow(row)), ), inventoryTokens, ), - [effectiveCachedRows, isDatasetMode, deferredFormatFilter, inventoryTokens], + [ + effectiveCachedRows, + isDatasetMode, + deferredFormatFilter, + inventoryTokens, + isVisibleInventoryRow, + ], ); const filteredLocalRows = useMemo( @@ -761,12 +788,42 @@ export function ModelsPage() { partitionByMatch( effectiveLocalRows.filter( (row) => + // Hidden-model filtering is model-only; datasets bypass it (and the + // format filter) the way Discover does, so a dataset whose + // id/title/path happens to contain an infra needle is not dropped. isDatasetMode || - matchesFormat(row.modelFormat, deferredFormatFilter), + (matchesFormat(row.modelFormat, deferredFormatFilter) && + isVisibleInventoryRow(row)), ), inventoryTokens, ), - [effectiveLocalRows, isDatasetMode, deferredFormatFilter, inventoryTokens], + [ + effectiveLocalRows, + isDatasetMode, + deferredFormatFilter, + inventoryTokens, + isVisibleInventoryRow, + ], + ); + + // Header tallies exclude infra/hidden models so the count matches the On + // Device list (a fresh install with only the bge embedder cached reads 0, + // not 1 over an empty list). Reuse isVisibleInventoryRow so a hidden row + // revealed by an active search is counted too, and datasets (never infra) + // keep their full count, mirroring the row filter above. + const visibleCachedCount = useMemo( + () => + effectiveCachedRows.filter( + (row) => isDatasetMode || isVisibleInventoryRow(row), + ).length, + [effectiveCachedRows, isDatasetMode, isVisibleInventoryRow], + ); + const visibleLocalCount = useMemo( + () => + effectiveLocalRows.filter( + (row) => isDatasetMode || isVisibleInventoryRow(row), + ).length, + [effectiveLocalRows, isDatasetMode, isVisibleInventoryRow], ); const filterResetSignature = useMemo( @@ -1315,15 +1372,15 @@ export function ModelsPage() { return ( ); }, [ - effectiveCachedRows.length, - effectiveLocalRows.length, + visibleCachedCount, + visibleLocalCount, allModelsView, setAllModelsView, inventorySort, @@ -1337,8 +1394,8 @@ export function ModelsPage() {
Date: Mon, 22 Jun 2026 08:45:13 -0700 Subject: [PATCH 02/43] Studio macOS: force anyio<4.14.0 via uv override (#6575) The macOS-arm studio venv still installs anyio 4.14.0 despite the constraints.txt cap from #6546. mlx-vlm / mlx-lm pull anyio>=4.14, which conflicts with the anyio<4.14.0 constraint; a uv -c constraint loses that conflict so 4.14.0 gets installed, reintroducing the cancel-scope RuntimeError on Python 3.13 (#6483). UV_OVERRIDE is already applied on macOS-arm via overrides-darwin-arm64.txt and a uv override wins the conflict, so cap anyio there too. macOS-arm now resolves anyio 4.13.0. --- .../requirements/single-env/overrides-darwin-arm64.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt index 2cd03d8b78..8558cd5b6c 100644 --- a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt +++ b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt @@ -3,3 +3,9 @@ # backtrack unsloth. Relax to match the pin -- per-model 5.x routing # happens at runtime via the side-car venvs. transformers>=4.57.6 + +# mlx-vlm / mlx-lm pull anyio>=4.14, which conflicts with the constraints.txt +# cap (anyio<4.14.0, #6483: 4.14+ breaks cancel scope on Python 3.13). A -c +# constraint loses that conflict on macOS-arm and 4.14.0 gets installed; an +# override wins it, so force anyio down here too. +anyio<4.14.0 From ce0323263eeb9d59ad8134ba7921ea53bffa074f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 08:45:34 -0700 Subject: [PATCH 03/43] Fix test isolation: restore sys.modules after the pre-import gate test (#6578) * Restore sys.modules in test_pre_import_gate_is_transformers_free The test pops transformers and utils.models.model_config from sys.modules to assert the pre-import security gate does not re-import them, but never put them back. A later importer then rebound a fresh utils.models.model_config, so tests that had captured the original instance missed their patches and hit the real path: test_vision_cache patches _is_vision_model_uncached on the original module, but is_vision_model (still bound to that original) ran the real network lookup instead. This produced 17 spurious failures whenever test_ssm_runtime ran before test_vision_cache in the same process. Snapshot the removed modules and restore the original objects in a finally, so the assertions still run against a clean slate while later tests see the same module instances they captured at import time. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/tests/test_ssm_runtime.py | 46 +++++++++++++++++------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/studio/backend/tests/test_ssm_runtime.py b/studio/backend/tests/test_ssm_runtime.py index 2f6bae9b79..bb0caa2887 100644 --- a/studio/backend/tests/test_ssm_runtime.py +++ b/studio/backend/tests/test_ssm_runtime.py @@ -445,20 +445,40 @@ def test_pre_import_gate_is_transformers_free(): import utils.security.file_security as fs import utils.security.consent as consent - for m in list(_sys.modules): - if m == "transformers" or m.startswith("transformers.") or m == "utils.models.model_config": + def _is_gated_module(name: str) -> bool: + return ( + name == "transformers" + or name.startswith("transformers.") + or name == "utils.models.model_config" + ) + + # Snapshot then remove the modules so we can assert the gate does not re-import them. + # Restore the originals afterwards (finally): popping utils.models.model_config without + # restoring it makes a later importer get a fresh instance, so tests that patched the + # first instance (e.g. test_vision_cache) miss and hit the real network path. + _saved = {m: _sys.modules[m] for m in list(_sys.modules) if _is_gated_module(m)} + for m in _saved: + _sys.modules.pop(m, None) + + try: + with patch.object(fs, "_fetch_security_status", return_value = None): + fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ()) + with patch.object( + consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}] + ): + from utils.security import evaluate_remote_code_consent_for_targets + evaluate_remote_code_consent_for_targets( + ["nvidia/Nemotron-H-8B"], trust_remote_code = True + ) + + assert "transformers" not in _sys.modules + assert "utils.models.model_config" not in _sys.modules + finally: + # Drop anything the gate imported, then rebind the original module objects so later + # tests see the same instances they captured at import time. + for m in [m for m in list(_sys.modules) if _is_gated_module(m) and m not in _saved]: _sys.modules.pop(m, None) - - with patch.object(fs, "_fetch_security_status", return_value = None): - fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ()) - with patch.object( - consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}] - ): - from utils.security import evaluate_remote_code_consent_for_targets - evaluate_remote_code_consent_for_targets(["nvidia/Nemotron-H-8B"], trust_remote_code = True) - - assert "transformers" not in _sys.modules - assert "utils.models.model_config" not in _sys.modules + _sys.modules.update(_saved) def test_pre_import_gate_skips_subdir_computation(): From 0689bd38428786d97702f0a66b91427d2f55b820 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:51:14 -0700 Subject: [PATCH 04/43] Studio: keep model downloads running across navigation and loads (#6573) * Studio: keep model downloads running across navigation and loads Downloads started from the chat model selector were tied to the staged pick lifecycle, so they were cancelled in cases where Hub downloads keep going. This makes the chat download flow behave like the Hub. - Leaving the chat route or switching thread/project/new chat now detaches the staging UI but keeps the in-flight transfer running in the global download manager (new keepDownload option on abandonStagedModel). - Staging a second pick no longer cancels the previous pick's download, so multiple models/variants can download at once. - Picking a model to download while another model is loading now starts the download in the background instead of refusing, since a download is independent of a load. * Studio: also background-download remote GGUF quants while a model loads isDownloadableHubRepo (wantManagerDownload) excludes GGUF sources, so an uncached remote GGUF quant picked from the chat selector while another model was loading fell through to the 'Another model is already loading' toast instead of downloading in the background. Treat an uncached remote hub GGUF as a background download too, matching the staged-pick download path. Addresses review feedback from gemini-code-assist and codex on PR #6573. * Studio: only toast a background download once it actually starts The chat background-download path (used when a model is already loading) fired the "Downloading in the background" toast unconditionally, but requestStart can return without starting a job: a cross-transport partial records a conflict that is only resolvable from the Hub download card, and a busy sibling variant returns after its own toast. So the user could be told a download started when none did, with no way to resolve the conflict from chat. requestStart now reports an outcome (started/conflict/busy/error). The chat path only shows the success toast on an actual start and points the user to the Hub when a transport conflict needs resolving. The Hub card surface keeps its existing behavior (it renders the conflict resolver, so it ignores the outcome). * Studio: report background-download outcome from real job state The chat background-download toast trusted requestStart's optimistic "started", but a start can no-op without throwing: startJob finalizes the job as "error" when the backend refuses or fails apiStart, its peer guard skips a fresh start, and hasActiveOrPendingStart trips on a snapshot, peer variant, or pending preflight that is not this request. So the user could be told a download started when none did. Derive the outcome from the actual job state of the exact key (running/cancelling = started, otherwise error/busy), so the toast only fires for a transfer that is really live. Also guard against re-downloading the model that is already loading: the /load flow downloads before it sets the checkpoint, and that fetch is not a download-manager job, so picking the same id+variant again would start a second transfer against the same cache. Detect that pick and surface a "this model is already loading" toast instead. --------- Co-authored-by: Daniel Han --- studio/frontend/src/app/routes/__root.tsx | 9 +- .../frontend/src/features/chat/chat-page.tsx | 86 +++++++++++++++---- .../chat/stores/chat-runtime-store.ts | 28 +++--- .../download-manager/transport-conflict.ts | 63 ++++++++++---- .../hub/download-manager/use-repo-download.ts | 6 +- 5 files changed, 138 insertions(+), 54 deletions(-) diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index b7e7bc01d2..77ba5788db 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -182,7 +182,9 @@ function RootLayout() { chatRuntime.setActiveThreadId(null); chatRuntime.setActiveProjectId(null); chatRuntime.setIncognito(false); - if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel(); + // Detach the staging UI but keep any in-flight download running, like Hub. + if (chatRuntime.pendingSelection) + chatRuntime.abandonStagedModel({ keepDownload: true }); void navigate({ to: "/chat", search: { new: crypto.randomUUID() }, @@ -205,7 +207,10 @@ function RootLayout() { chatRuntime.setActiveProjectId(null); chatRuntime.setActiveThreadId(null); chatRuntime.setIncognito(false); - if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel(); + // Leaving chat must not kill an in-flight download: detach the staging UI + // but keep the transfer running in the manager, like a Hub download. + if (chatRuntime.pendingSelection) + chatRuntime.abandonStagedModel({ keepDownload: true }); }, [isChatRoute]); return ( diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index daad2c4524..ac0c4de75c 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -18,6 +18,10 @@ import { import { useSidebar } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; import { useLatestRef } from "@/features/hub/hooks/use-latest-ref"; +import { + DOWNLOAD_KIND, + downloadManager, +} from "@/features/hub/download-manager"; import { type NativeIntent, NativeModelChip, @@ -1093,6 +1097,11 @@ export function ChatPage({ const abandonStaged = useCallback(() => { useChatRuntimeStore.getState().abandonStagedModel(); }, []); + // Detach a staged pick on navigation without cancelling its download: the + // transfer keeps running in the manager and lands in cache, like Hub. + const detachStaged = useCallback(() => { + useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true }); + }, []); // Tracks whether the chat page is still mounted, so a staged-load failure that // resolves after the user left chat doesn't resurrect the abandoned pick. const mountedRef = useRef(true); @@ -1620,8 +1629,8 @@ export function ChatPage({ const prev = prevChatContextRef.current; prevChatContextRef.current = chatContextKey; if (prev === null || prev === chatContextKey) return; - abandonStaged(); - }, [chatContextKey, abandonStaged]); + detachStaged(); + }, [chatContextKey, detachStaged]); const hasActiveModel = Boolean(inferenceParams.checkpoint); // Load immediately, or — when "Load on selection" is off — stage the pick so @@ -1639,25 +1648,70 @@ export function ChatPage({ (!hasGgufSource(selection) && !wantManagerDownload) || (store.loadOnSelection && selection.isDownloaded) ) { - // Abandon any staged pick first so its edited knobs (e.g. a custom - // context length) don't leak into this immediate load -- resolveLoad - // reads customContextLength before checking the target is GGUF. - abandonStaged(); + // Detach any staged pick first so its edited knobs don't leak into this + // immediate load. Detach (not abandon) keeps its download running. + detachStaged(); await selectModel(selection); return; } - // Refuse staging while a load is in flight (it would be silently dropped); - // the immediate-load branch above is already guarded in selectModel. + // Loads can't queue behind each other, but a download is independent: if + // the pick needs downloading, start it in the manager so it runs alongside + // the load. Nothing to download (already on device) just waits. if (store.modelLoading) { - toast.info("Another model is already loading", { - description: "Wait for it to finish or cancel it first.", - }); + // Both an uncached non-GGUF snapshot (wantManagerDownload) and an + // uncached remote GGUF quant download through the manager, so either can + // run in the background while another model loads. wantManagerDownload + // excludes GGUF by design, so the GGUF case is checked separately. + const wantBackgroundDownload = + wantManagerDownload || + (selection.source === "hub" && + hasGgufSource(selection) && + !selection.isDownloaded); + // The model currently loading already downloads as part of its own load + // (the /load flow fetches before setting the checkpoint), so re-picking + // it must not kick off a second transfer against the same cache. + const isLoadingThisPick = + !!loadingModel && + normalizeModelRef(loadingModel.id) === + normalizeModelRef(selection.id) && + (loadingModel.ggufVariant ?? null) === (selection.ggufVariant ?? null); + if (isLoadingThisPick) { + toast.info("This model is already loading", { + description: "It's downloading as part of the load in progress.", + }); + } else if (wantBackgroundDownload) { + // Only claim the download started once a job is actually created. A + // transport conflict records state that is only resolvable from the + // Hub download card, so point the user there instead of showing a + // success toast for a transfer that never began; "busy" and "error" + // already surface their own toasts. + const outcome = await downloadManager.requestStart({ + kind: DOWNLOAD_KIND.MODEL, + repoId: selection.id, + variant: selection.ggufVariant ?? null, + expectedBytes: selection.expectedBytes ?? 0, + }); + if (outcome === "started") { + toast.info("Downloading in the background", { + description: + "It'll be ready to load once the current model finishes.", + }); + } else if (outcome === "conflict") { + toast.info("Resume this download from the Hub", { + description: + "An earlier partial download used a different transport. Open the Hub tab to resume or restart it.", + }); + } + } else { + toast.info("Another model is already loading", { + description: "Wait for it to finish or cancel it first.", + }); + } return; } - // Tear down any existing staged pick first so its in-flight download is - // cancelled, not left running after we rebind to the new pick. With the - // toggle on, autoLoad downloads silently then loads; off stages for the sheet. - abandonStaged(); + // Detach the prior staged pick (keeping its download) before rebinding, so + // a second pick downloads alongside the first instead of cancelling it. + detachStaged(); store.stageModel({ id: selection.id, isLora: selection.isLora, @@ -1670,7 +1724,7 @@ export function ChatPage({ autoLoad: store.loadOnSelection, }); }, - [abandonStaged, selectModel], + [detachStaged, selectModel, loadingModel], ); const loadNativeModelIntent = useCallback( async (intent: NativeIntent, loadingDescription: string) => { diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 4cca3dc2e3..059a454305 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -778,9 +778,10 @@ type ChatRuntimeStore = { /** Stage a pick for a deferred load: revert knobs to the loaded baseline, * record the selection, and open the settings sheet. */ stageModel: (selection: PendingModelSelection) => void; - /** Abandon a staged pick without loading: revert the knobs to the loaded - * baseline and clear the pending selection. */ - abandonStagedModel: () => void; + /** Abandon a staged pick without loading: revert knobs to the loaded baseline + * and clear the pending selection. Cancels its in-flight download too, unless + * `keepDownload` is set (navigation keeps the transfer running, like Hub). */ + abandonStagedModel: (opts?: { keepDownload?: boolean }) => void; setCustomContextLength: (v: number | null) => void; setChatTemplateOverride: (template: string | null) => void; setPendingAudio: (base64: string, name: string) => void; @@ -1544,15 +1545,9 @@ export const useChatRuntimeStore = create((set, get) => ({ // Refuse staging mid-load: post-load cleanup would silently drop the queued // pick. stageOrLoad toasts first for callers that can. if (get().modelLoading) return; + // Rebinding to a new pick keeps the prior pick's download running so the + // user can queue multiple downloads at once (Hub-style). set((s) => { - if ( - s.pendingSelection && - (s.pendingSelection.id !== selection.id || - (s.pendingSelection.ggufVariant ?? null) !== - (selection.ggufVariant ?? null)) - ) { - cancelStagedModelDownload(s.pendingSelection); - } return { ...loadedBaselineSettings(s), pendingSelection: selection, @@ -1566,14 +1561,13 @@ export const useChatRuntimeStore = create((set, get) => ({ }; }); }, - abandonStagedModel: () => { + abandonStagedModel: (opts) => { const { pendingSelection } = get(); if (!pendingSelection) return; - // Cancel the staged pick's in-flight download so it doesn't keep running - // after the staging UI is gone. Centralized here so every abandon path - // (sheet close, thread switch, route exit, new chat) cancels it, including - // root-level callers that have no access to the useRepoDownload hook. - cancelStagedModelDownload(pendingSelection); + // Cancel the staged pick's in-flight download (centralized for every abandon + // path: sheet close, thread switch, route exit, new chat). `keepDownload` + // opts out so navigation leaves the transfer running, like a Hub download. + if (!opts?.keepDownload) cancelStagedModelDownload(pendingSelection); set((s) => ({ ...loadedBaselineSettings(s), pendingSelection: null })); }, setCustomContextLength: (customContextLength) => set({ customContextLength }), diff --git a/studio/frontend/src/features/hub/download-manager/transport-conflict.ts b/studio/frontend/src/features/hub/download-manager/transport-conflict.ts index 9125abed70..3267a6ae05 100644 --- a/studio/frontend/src/features/hub/download-manager/transport-conflict.ts +++ b/studio/frontend/src/features/hub/download-manager/transport-conflict.ts @@ -80,24 +80,50 @@ async function activeSiblingTransport( return null; } +// Outcome of a start request so callers can tell whether a transfer for this +// exact request is actually live before telling the user it began. "started" +// means a running/cancelling job exists for this key (a fresh start or an +// already-active one). "conflict" means a transport partial conflict was +// recorded and must be resolved from the Hub download card; "busy" means the +// repo is occupied by a sibling variant/snapshot/pending start that is not this +// transfer; "error" means the start failed or was refused. +export type DownloadStartOutcome = "started" | "conflict" | "busy" | "error"; + +// A start can no-op without throwing: the backend can refuse it (startJob +// finalizes "error"), startJob's peer guard can skip it, or +// hasActiveOrPendingStart can trip on a snapshot/peer/pending that is not this +// request. Derive the outcome from the actual job state of this exact key so +// callers never claim a download began when it did not. +function isJobActiveFor(req: DownloadRequest): boolean { + const job = getState().jobs[jobKeyOf(req.kind, req.repoId, req.variant)]; + return Boolean(job && ACTIVE_STATES.has(job.state)); +} + async function runWithPendingStartGuard( req: DownloadRequest, - action: () => Promise, -): Promise { + action: () => Promise, +): Promise { const startKey = pendingStartKey(req); - if (hasActiveOrPendingStart(req)) return; + // Already active or pending for the repo: only report "started" when this + // exact request is the live transfer; a peer/snapshot/pending start has not. + if (hasActiveOrPendingStart(req)) { + return isJobActiveFor(req) ? "started" : "busy"; + } runtimeRegistry.pendingStartRepoKeys.add(startKey); try { - await action(); + return await action(); } catch (error) { reportConflictStartError(error); + return "error"; } finally { runtimeRegistry.pendingStartRepoKeys.delete(startKey); } } -export async function requestStart(req: DownloadRequest): Promise { - await runWithPendingStartGuard(req, async () => { +export async function requestStart( + req: DownloadRequest, +): Promise { + return runWithPendingStartGuard(req, async () => { let mode: TransportMode = getTransportMode(); try { mode = await effectiveTransportMode(mode); @@ -119,7 +145,7 @@ export async function requestStart(req: DownloadRequest): Promise { ? "This repository is currently downloading with Xet. Switch to Xet or wait for it to finish." : "This repository is currently downloading with HTTP. Switch to HTTP or wait for it to finish.", }); - return; + return "busy"; } } catch (err) { console.warn("Active download transport check failed.", err); @@ -139,7 +165,7 @@ export async function requestStart(req: DownloadRequest): Promise { }, pending: req, }); - return; + return "conflict"; } if (status.has_partial && !status.last_transport) { toast.info("Restarting this download", { @@ -163,7 +189,7 @@ export async function requestStart(req: DownloadRequest): Promise { "Starting with HTTP so an existing partial is not discarded. Switch transport to retry with Xet.", }); await startJob(req, { useXet: false }); - return; + return isJobActiveFor(req) ? "started" : "error"; } toast.warning("Couldn't verify existing partial download", { description: @@ -171,6 +197,7 @@ export async function requestStart(req: DownloadRequest): Promise { }); } await startJob(req, { useXet: mode === TRANSPORT.XET }); + return isJobActiveFor(req) ? "started" : "error"; }); } @@ -178,22 +205,24 @@ export function resumeConflict(conflictKey: string): void { const entry = getState().conflicts[conflictKey]; if (!entry) return; setConflict(conflictKey, null); - void runWithPendingStartGuard(entry.pending, () => - startJob(entry.pending, { + void runWithPendingStartGuard(entry.pending, async () => { + await startJob(entry.pending, { useXet: entry.info.previous === TRANSPORT.XET, - }), - ); + }); + return "started"; + }); } export function restartConflict(conflictKey: string): void { const entry = getState().conflicts[conflictKey]; if (!entry) return; setConflict(conflictKey, null); - void runWithPendingStartGuard(entry.pending, () => - startJob(entry.pending, { + void runWithPendingStartGuard(entry.pending, async () => { + await startJob(entry.pending, { useXet: entry.info.next === TRANSPORT.XET, - }), - ); + }); + return "started"; + }); } export function cancelConflict(conflictKey: string): void { diff --git a/studio/frontend/src/features/hub/download-manager/use-repo-download.ts b/studio/frontend/src/features/hub/download-manager/use-repo-download.ts index 46b5fae6bb..9b9f33d8a2 100644 --- a/studio/frontend/src/features/hub/download-manager/use-repo-download.ts +++ b/studio/frontend/src/features/hub/download-manager/use-repo-download.ts @@ -120,8 +120,10 @@ export function useRepoDownload(config: RepoDownloadConfig): DownloadJob { ); const requestStartDownload = useCallback( - (variant: string | null, expectedBytes: number) => { - return downloadManager.requestStart({ + async (variant: string | null, expectedBytes: number) => { + // This surface renders the conflict resolver (transportConflict), so the + // start outcome is handled by the card UI; the awaited result is ignored. + await downloadManager.requestStart({ kind, repoId, variant, From c7eaaaeaef05239419df072d9e4d3539ed5093f5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 08:58:48 -0700 Subject: [PATCH 05/43] Versioning --- pyproject.toml | 6 +++--- unsloth/models/_utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83d65bc1a3..dd957766b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ triton = [ ] huggingfacenotorch = [ - "unsloth_zoo>=2026.6.6", + "unsloth_zoo>=2026.6.7", "wheel>=0.42.0", "packaging", "numpy", @@ -92,7 +92,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.6.6", + "unsloth_zoo>=2026.6.7", "torchvision", "unsloth[triton]", ] @@ -582,7 +582,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.6.6", + "unsloth_zoo>=2026.6.7", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 2365975cdd..7a056cef82 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.6.8" +__version__ = "2026.6.9" __all__ = [ "SUPPORTS_BFLOAT16", From c9761749ecbcd0bb13feef5e8173537c73110974 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 09:05:22 -0700 Subject: [PATCH 06/43] Studio: correct the anyio<4.14 pin rationale (mixed-install ImportError, not a 4.14 cancel-scope bug) (#6579) * Studio: correct the anyio<4.14 pin rationale (mixed-install ImportError) The pin comments said "anyio 4.14+ breaks cancel scope on Python 3.13", but a clean anyio 4.14.0 works on 3.13 (cancel scopes, Event, and the asyncio backend import all pass). The actual failure is a half-resolved install: anyio 4.14 added TaskHandle, imported by __init__.py and _backends/_asyncio from _core/_tasks. When a stale 4.13 _core/_tasks (no TaskHandle) sits under 4.14's importers, the import raises ImportError and 500s the server. Correct the rationale; the <4.14 pin still stands as the way to keep one consistent anyio version. * Clarify the anyio override comment (mixed-install ImportError, not a 4.14 cancel-scope bug) --- studio/backend/requirements/no-torch-runtime.txt | 2 +- studio/backend/requirements/single-env/constraints.txt | 7 +++++-- .../requirements/single-env/overrides-darwin-arm64.txt | 10 ++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/studio/backend/requirements/no-torch-runtime.txt b/studio/backend/requirements/no-torch-runtime.txt index b0157cfea0..a611c009fb 100644 --- a/studio/backend/requirements/no-torch-runtime.txt +++ b/studio/backend/requirements/no-torch-runtime.txt @@ -56,7 +56,7 @@ httpx httpcore certifi idna -anyio>=3.0,<4.14.0 # 4.14+ breaks cancel scope on Py3.13 (#6483) +anyio>=3.0,<4.14.0 # one consistent <4.14: 4.14's TaskHandle importers over a stale 4.13 _core/_tasks -> ImportError (#6483) sniffio h11 diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt index aad4c38664..a916c6fc75 100644 --- a/studio/backend/requirements/single-env/constraints.txt +++ b/studio/backend/requirements/single-env/constraints.txt @@ -13,8 +13,11 @@ fastmcp>=3.0.2 mcp>=1.24,<2 websockets>=15.0.1 -# anyio 4.14+ breaks cancel scope on Python 3.13 (#6483). Global cap so later -# with-deps steps (studio.txt, data-designer-deps.txt) can't re-resolve it up. +# Keep anyio on one consistent <4.14 line. anyio 4.14 added TaskHandle (imported +# by __init__.py and the asyncio backend from _core/_tasks); a clean 4.14 is fine +# on 3.13. The real failure (#6483) is a half-resolved install: a stale 4.13 +# _core/_tasks (no TaskHandle) under 4.14's importers raises ImportError and 500s +# the server. Global cap so later with-deps steps can't re-resolve it up. anyio<4.14.0 pandas==2.3.3 diff --git a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt index 8558cd5b6c..a0e73c7efc 100644 --- a/studio/backend/requirements/single-env/overrides-darwin-arm64.txt +++ b/studio/backend/requirements/single-env/overrides-darwin-arm64.txt @@ -4,8 +4,10 @@ # happens at runtime via the side-car venvs. transformers>=4.57.6 -# mlx-vlm / mlx-lm pull anyio>=4.14, which conflicts with the constraints.txt -# cap (anyio<4.14.0, #6483: 4.14+ breaks cancel scope on Python 3.13). A -c -# constraint loses that conflict on macOS-arm and 4.14.0 gets installed; an -# override wins it, so force anyio down here too. +# mlx-vlm / mlx-lm pull anyio>=4.14, which fights the constraints.txt cap +# (anyio<4.14.0). The -c constraint loses that fight on macOS-arm, leaving a +# half-resolved anyio (4.14 importers over a stale 4.13 _core/_tasks with no +# TaskHandle) that ImportErrors and 500s the server (#6483; clean 4.14 is fine, +# it is the mix that breaks). An override wins the fight, so force one +# consistent <4.14 here too. anyio<4.14.0 From 7ecbf5a770623da25891a3df0be881bf72ee13a2 Mon Sep 17 00:00:00 2001 From: Saicharan Ramineni <84414237+GodlyDonuts@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:06:03 -0400 Subject: [PATCH 07/43] Use UTF-8 for Python code-execution subprocess I/O (#6489 class) (#6548) * Use UTF-8 for Python code-execution subprocess I/O Studio's code-execution tool already tells the child to emit UTF-8 (PYTHONIOENCODING=utf-8 in _build_safe_env), but _python_exec writes the temp script and decodes the subprocess pipe with the OS default codec. On Windows (cp1252), non-ASCII in model-written code or its output -- arrows, CJK, emoji -- raises UnicodeEncodeError / UnicodeDecodeError and breaks execution. Complete the UTF-8 wiring in core/inference/tools.py: - write the temp script with encoding="utf-8" - decode _python_exec stdout as utf-8, errors="replace" - set PYTHONIOENCODING=utf-8 in _build_bypass_env too (matches _build_safe_env, so the bypass path's child also emits utf-8) The child is python with PYTHONIOENCODING=utf-8, so it emits UTF-8 regardless of the console code page and the decode is always correct. Shell execution via cmd.exe has a separate console-code-page story and is left to a follow-up. Refs unslothai/unsloth#6489 * Scope Python exec UTF-8 env to Python tool * Make bash bypass test robust to a host-set PYTHONIOENCODING for PR #6548 Bypass mode preserves benign host env vars, so a host-set PYTHONIOENCODING was inherited into the bash bypass env and tripped the new assertion even though _bash_exec never adds it. Clear it in the test so the assertion checks _bash_exec, not the runner environment. --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/core/inference/tools.py | 12 ++++++- .../backend/tests/test_bypass_permissions.py | 6 +++- studio/backend/tests/test_exec_utf8.py | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 studio/backend/tests/test_exec_utf8.py diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 6960310018..a5c193ff39 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2545,14 +2545,24 @@ def _python_exec( pass try: fd, tmp_path = tempfile.mkstemp(suffix = ".py", prefix = "studio_exec_", dir = workdir) - with os.fdopen(fd, "w") as f: + # utf-8 so non-ASCII in model-written code survives the OS default codec + # (Windows cp1252 would otherwise raise UnicodeEncodeError). + with os.fdopen(fd, "w", encoding = "utf-8") as f: f.write(code) safe_env = _build_bypass_env(workdir) if disable_sandbox else _build_safe_env(workdir) + if disable_sandbox: + # Match the sandboxed Python path without changing bypass shell I/O. + safe_env = dict(safe_env) + safe_env["PYTHONIOENCODING"] = "utf-8" popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True, + # Decode child output as utf-8 (it emits utf-8 via PYTHONIOENCODING); + # replace so non-ASCII output never crashes the read on Windows. + encoding = "utf-8", + errors = "replace", cwd = workdir, env = safe_env, ) diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 563f146816..d92509a5fe 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -135,6 +135,7 @@ def test_python_bypass_uses_bypass_preexec_and_bypass_env(captured_popen, monkey assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec env = captured_popen["kwargs"]["env"] assert env.get("HOSTVAR") == "benign-xyz" + assert env.get("PYTHONIOENCODING") == "utf-8" assert "HF_TOKEN" not in env @@ -151,9 +152,12 @@ def test_bash_blocklist_skipped_when_bypassed(captured_popen): @_POSIX_ONLY -def test_bash_bypass_uses_bypass_preexec(captured_popen): +def test_bash_bypass_uses_bypass_preexec(captured_popen, monkeypatch): + # bypass inherits benign host vars; clear so we assert _bash_exec adds none. + monkeypatch.delenv("PYTHONIOENCODING", raising = False) _bash_exec("echo hi", None, 5, "t", disable_sandbox = True) assert captured_popen["kwargs"]["preexec_fn"] is tools._bypass_preexec + assert "PYTHONIOENCODING" not in captured_popen["kwargs"]["env"] # ── real end-to-end python execution under bypass ─────────────────── diff --git a/studio/backend/tests/test_exec_utf8.py b/studio/backend/tests/test_exec_utf8.py new file mode 100644 index 0000000000..90b78754ed --- /dev/null +++ b/studio/backend/tests/test_exec_utf8.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""_python_exec must round-trip non-ASCII output end to end. + +Model-written code routinely contains non-ASCII (arrows, CJK, emoji). The temp +script and the child's stdout pipe both have to be UTF-8 or it crashes/garbles +on Windows, whose default codec is cp1252. Mirrors the report in +unslothai/unsloth#6489. The child is ``python`` with PYTHONIOENCODING=utf-8, so +it emits UTF-8 on every OS; this proves the round-trip on a UTF-8 host and +guards against a regression to the OS default codec. +""" + +import sys +from pathlib import Path + +import pytest + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +from core.inference.tools import _python_exec + +# Arrow, em-dash, accent, CJK, check mark, astral-plane emoji -- none encodable +# in cp1252, so the OS default codec would raise on write or read. +_UNICODE = "café — 数字 → ✓ 😀" + + +@pytest.mark.parametrize("disable_sandbox", [False, True]) +def test_python_exec_round_trips_non_ascii(disable_sandbox): + out = _python_exec(f"print({_UNICODE!r})", disable_sandbox = disable_sandbox) + assert _UNICODE in out, repr(out) From 643e13ac334f726d25a12358ca6c2abf6f313b28 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 09:15:22 -0700 Subject: [PATCH 08/43] Bump install.sh / install.ps1 pin to unsloth>=2026.6.9 (#6580) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 765f33b1ff..efb54efa2b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2146,7 +2146,7 @@ exit 0 if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -2160,7 +2160,7 @@ exit 0 } } } else { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -2226,7 +2226,7 @@ exit 0 if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic } @@ -2238,7 +2238,7 @@ exit 0 } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } } else { $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -2266,7 +2266,7 @@ exit 0 Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index 7a2e18e374..a3b2734dac 100755 --- a/install.sh +++ b/install.sh @@ -2621,7 +2621,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2634,7 +2634,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2838,7 +2838,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" # Same pydantic-with-deps trick as the migrated branch. run_install_cmd_retry "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2856,7 +2856,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2888,7 +2888,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto + run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." From 655b0cbcee1d68b22629e3ceeab7f84c7d685af8 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:57:01 -0700 Subject: [PATCH 09/43] Studio: default Hub Discover scope to all models (#6593) - Discover defaults to the whole Hub instead of the unsloth org; an explicit Unsloth choice is still remembered - Discover models placeholder reads Search all models to match - Give the Unsloth/All scope pill a min width so it stays readable --- .../frontend/src/features/hub/catalog/models-toolbar.tsx | 2 +- .../src/features/hub/catalog/owner-scope-toggle.tsx | 4 ++-- studio/frontend/src/features/hub/hub-page.tsx | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/studio/frontend/src/features/hub/catalog/models-toolbar.tsx b/studio/frontend/src/features/hub/catalog/models-toolbar.tsx index c8adfb54f5..48f7fcffaa 100644 --- a/studio/frontend/src/features/hub/catalog/models-toolbar.tsx +++ b/studio/frontend/src/features/hub/catalog/models-toolbar.tsx @@ -252,7 +252,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({ ? `Search on-device ${isDataset ? "datasets" : "models"}` : isDataset ? "Search datasets" - : "Search models" + : "Search all models" } className={cn( "field-soft h-9 rounded-full !border-0 pl-10 text-[13px] placeholder:text-muted-foreground/80 focus-visible:!ring-0", diff --git a/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx b/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx index fed8568f99..5f36f5d031 100644 --- a/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx +++ b/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx @@ -28,8 +28,8 @@ export function OwnerScopeToggle({ onValueChange={onChange} ariaLabel="Publisher scope" align="end" - // Extra gap so the chevron sits a touch further from the label. - className="h-8 gap-1.5 text-[11.5px]" + // Extra gap before the chevron; min-width keeps the pill readable. + className="h-8 min-w-[96px] gap-1.5 text-[11.5px]" /> ); } diff --git a/studio/frontend/src/features/hub/hub-page.tsx b/studio/frontend/src/features/hub/hub-page.tsx index b3c9dd0dbe..e379d7f2ee 100644 --- a/studio/frontend/src/features/hub/hub-page.tsx +++ b/studio/frontend/src/features/hub/hub-page.tsx @@ -90,18 +90,19 @@ const ALL_MODELS_VIEW_STORAGE_KEY = "unsloth.hub.allModelsView"; const INVENTORY_SORT_STORAGE_KEY = "unsloth.hub.inventorySort"; const OWNER_SCOPE_STORAGE_KEY = "unsloth.hub.ownerScope"; -/** Discover browsing scope: only the unsloth org (default) or the whole Hub. */ +/** Discover browsing scope: the whole Hub (default) or only the unsloth org. */ export type OwnerScope = "unsloth" | "all"; function readOwnerScopePreference(): OwnerScope { if (typeof window === "undefined") { - return "unsloth"; + return "all"; } try { const value = window.localStorage.getItem(OWNER_SCOPE_STORAGE_KEY); - return value === "all" ? "all" : "unsloth"; + // Default to the whole Hub; only honor an explicit "unsloth" preference. + return value === "unsloth" ? "unsloth" : "all"; } catch { - return "unsloth"; + return "all"; } } From 45c01c09bc56767a4a77fe055ec9fff105586125 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:11:45 -0700 Subject: [PATCH 10/43] Studio: model picker search placeholder, Search Hub tooltip, list polish (#6592) Polish for the in-chat model picker popover and its guided-tour step. - Search box placeholder reads Search Unsloth models, matching the Unsloth-only listing. - Search Hub button shows a Search all models tooltip on hover. - Floating Eject pill moves 1px lower so it sits closer to the bottom edge. - Results list max height trimmed by 1px (21rem to 335px) from the bottom only. - Chat guided tour Two tabs step updated to describe Unsloth-scoped search plus Search Hub for all of Hugging Face. --- .../assistant-ui/model-selector/pickers.tsx | 29 +++++++++++-------- .../frontend/src/features/chat/tour/steps.tsx | 7 +++-- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 46b6fe2e63..90c1c04106 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -2336,7 +2336,7 @@ export function HubModelPicker({ setQuery(event.target.value)} - placeholder="Search models" + placeholder="Search Unsloth models" data-model-picker-search-input={true} className="field-soft h-9 border-0 pl-8 pr-8" /> @@ -2345,15 +2345,20 @@ export function HubModelPicker({ )}
{onBrowseHub ? ( - + + + + + Search all models + ) : null} @@ -2386,7 +2391,7 @@ export function HubModelPicker({ // Height tracks the content up to the cap, so short lists do not // leave white space. scroll-py + symmetric px keep the focus ring off // the overflow clip edges during keyboard nav. - "model-list-scroll max-h-[21rem] overflow-y-auto scroll-py-1.5 px-0.5 mr-1", + "model-list-scroll max-h-[335px] overflow-y-auto scroll-py-1.5 px-0.5 mr-1", listScrolled && "is-scrolled", listMoreBelow && "is-bottom-faded", )} @@ -3362,7 +3367,7 @@ export function HubModelPicker({ {/* Floating eject pill: overlaid on the list bottom, outside the scroll so the edge fade never touches it. Only the pill catches clicks. */} {onEject ? ( -
+
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index ac0c4de75c..7d4292669d 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -2506,7 +2506,6 @@ export function ChatPage({ onClick={() => setSettingsOpen(true)} className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Open run settings" - data-tour="chat-settings" > Run settings Chat inference settings -
{settingsContent}
+
+ {settingsContent} +
); @@ -1783,6 +1785,7 @@ export function ChatSettingsPanel({ return (