From 49d4c61623d55ffd36a5c94979ab651c6fe5c68c Mon Sep 17 00:00:00 2001 From: oobabooga Date: Mon, 22 Jun 2026 02:19:09 -0300 Subject: [PATCH 001/306] studio/frontend: Prevent staged model's load settings from disappearing while it loads (#6458) * Studio: keep staged model's load settings visible while it loads * Studio: pin staged load settings at click time, match loading pick by variant * Studio: drop any stale staged pick after a successful load * Studio: disable staged Load button while a different model loads When a model is staged with "Load on selection" off and a different model (or a different GGUF variant of the same repo) is already loading, selectModel's in-flight-load guard matches on id + native path token only, so the click was silently deduped to a no-op. The staged Load button stayed enabled but did nothing, and the stale stage was then cleared once the other load finished. Disable the staged Load button (showing "Another model loading...") whenever a different model is loading, so it is no longer an enabled no-op. The stage stays put and the user can retry once the in-flight load settles. * Studio: refuse loading a different model while one is in flight The in-flight-load guard in selectModel matched on id + native path token only, so a different GGUF variant of the same repo fell through and was silently deduped to a no-op. The earlier commit disabled the staged Load button for this case, but other entry points (e.g. selecting a different quant from the model picker with "Load on selection" on) still hit selectModel directly and no-op'd. Make the guard variant-aware (id + GGUF variant + native path token) and, for a genuinely different model while a load is in flight, surface a "Another model is already loading" toast instead of silently returning. The load path has no clean supersession, so a second concurrent load is not started; the user is told to wait or cancel. Centralized in selectModel so every entry point is covered. * Studio: lock staged model's load settings while it loads The staged Load button snapshots context length, KV cache dtype, speculative decoding, draft tokens, and tensor parallelism at click time, but the settings sheet stays mounted during the load, so edits made while "Loading..." shows were silently ignored and then overwritten by the load response. Disable those controls while the staged pick is loading so the visible state matches what the run actually uses. * Studio: refuse to stage a model while another load is in flight With Load on selection off, selecting a model mid-load staged it into pendingSelection and showed a disabled "Another model loading..." button, implying the user could retry once the load settled. The post-load cleanup then treated that queued pick as stale and silently cancelled and cleared it. Refuse to stage while a load (or a cancel's background unload) is in flight: stageModel no-ops in that state so every entry point (the chat selector and the Hub) is covered, and stageOrLoad surfaces a toast on the common path. The immediate-load path is unchanged; selectModel already rejects a concurrent load. * Tighten staged-load guard comments --------- Co-authored-by: Daniel Han --- .../frontend/src/features/chat/chat-page.tsx | 26 ++-- .../src/features/chat/chat-settings-sheet.tsx | 96 ++++++++++--- .../chat/hooks/use-chat-model-runtime.ts | 135 ++++++++++++------ .../hooks/use-staged-model-preparation.ts | 12 +- .../chat/stores/chat-runtime-store.ts | 24 +++- 5 files changed, 211 insertions(+), 82 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 758aa00e1a..83452c2ea8 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -1607,6 +1607,14 @@ export function ChatPage({ 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. + if (store.modelLoading) { + 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. abandonStaged(); @@ -2495,6 +2503,7 @@ export function ChatPage({ ); }} externalProviderType={activeExternalProviderType} + loadingModel={loadingModel} onReloadModel={() => { const state = useChatRuntimeStore.getState(); if (state.params.checkpoint) { @@ -2512,22 +2521,23 @@ export function ChatPage({ if (!pending) return; const keyAtLoad = chatContextKey; // forceReload: the staged model isn't loaded yet, so bypass the - // same-checkpoint dedupe (and selectModel clears pendingSelection). - // keepSpeculative: honor the speculative mode set on the sidebar. + // same-checkpoint dedupe. keepSpeculative: honor the speculative mode + // set on the sidebar. void selectModel({ ...pending, forceReload: true, keepSpeculative: true, throwOnError: true, }).catch(() => { - // Recoverable failure (expired token, gated repo, OOM…): selectModel - // cleared the pick but left the edited knobs intact. + // Recoverable failure (expired token, gated repo, OOM…): the pick is + // cleared only on success, so it normally stays staged with edited + // knobs intact — nothing to restore. const store = useChatRuntimeStore.getState(); - // A pick staged meanwhile owns the knobs now; leave it untouched. + // Still staged (this pick, or a newer one queued meanwhile): leave it. if (store.pendingSelection) return; - // Restore (not re-stage, which would reset the knobs) only if the - // staged-load is still wanted: same chat context, sheet still open, - // page still mounted. + // Cleared mid-load (sheet closed / switched chats). Re-stage only if + // the staged-load is still wanted: same chat context, sheet still + // open, page still mounted. const stillWanted = mountedRef.current && store.settingsPanelOpen && diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index bf0cfcb277..77d7cf4c6d 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -52,6 +52,7 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { Slider } from "@/components/ui/slider"; +import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { InfoHint } from "@/components/ui/info-hint"; @@ -99,6 +100,7 @@ import { } from "./provider-capabilities"; import { isPendingGguf, + pendingSelectionMatches, useChatRuntimeStore, } from "./stores/chat-runtime-store"; import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section"; @@ -145,6 +147,7 @@ function NumericValueInput({ className, ariaLabel, size: sizeAttr, + disabled = false, }: { value: number; min?: number; @@ -155,6 +158,7 @@ function NumericValueInput({ className?: string; ariaLabel?: string; size?: number; + disabled?: boolean; }) { const [focused, setFocused] = useState(false); const [draft, setDraft] = useState(""); @@ -177,6 +181,7 @@ function NumericValueInput({ void; + /** The in-flight load (id + GGUF variant + native path token), or null when + * idle. Used to show a loading state for the staged pick only — not for an + * unrelated load or a cancel's background unload. */ + loadingModel?: { + id: string; + ggufVariant?: string | null; + nativePathToken?: string | null; + } | null; /** Loads the staged `pendingSelection` (deferred "Load on selection" flow). */ onLoadPendingModel?: () => void; /** Download progress (0–1) for a staged GGUF being fetched, or null when idle. */ @@ -457,6 +470,7 @@ export function ChatSettingsPanel({ onExternalProviderChange, externalProviderType = null, onReloadModel, + loadingModel = null, onLoadPendingModel, stagedDownloadFraction, onCancelStagedDownload, @@ -475,6 +489,19 @@ export function ChatSettingsPanel({ !isExternalModel || Boolean(providerCapabilities?.presencePenalty); const isMobile = useIsMobile(); const pendingSelection = useChatRuntimeStore((s) => s.pendingSelection); + // "Loading" only when the in-flight load IS this staged pick (full id + GGUF + // variant + native token match), not an unrelated load or a cancel's + // background unload. The variant matters: a different quant of the same repo + // staged mid-load must not read as this one loading. + const stagedLoading = + loadingModel != null && + pendingSelectionMatches(pendingSelection, { + id: loadingModel.id, + ggufVariant: loadingModel.ggufVariant, + nativePathToken: loadingModel.nativePathToken, + }); + // Load settings are snapshotted at click time; lock them while loading. + const modelControlsDisabled = stagedLoading; const abandonStagedModel = useChatRuntimeStore((s) => s.abandonStagedModel); const resetModelSettingsToLoaded = useChatRuntimeStore( (s) => s.resetModelSettingsToLoaded, @@ -867,10 +894,14 @@ export function ChatSettingsPanel({ {pendingSelection && ( - {stagedLabel} is staged, not loaded yet + {stagedLoading + ? `Loading ${stagedLabel}…` + : `${stagedLabel} is staged, not loaded yet`} - Set the options below, then choose Load model to load it. + {stagedLoading + ? "Applying your settings." + : "Set the options below, then choose Load model to load it."} )} @@ -898,6 +929,7 @@ export function ChatSettingsPanel({ }} ariaLabel="Context Length" size={8} + disabled={modelControlsDisabled} /> {ggufMaxContextLength != null && typeof ctxDisplayValue === "number" && @@ -944,6 +977,7 @@ export function ChatSettingsPanel({
{ setSpeculativeType(v); @@ -1057,6 +1092,7 @@ export function ChatSettingsPanel({
@@ -1118,31 +1155,46 @@ export function ChatSettingsPanel({ {Math.round((stagedDownloadFraction ?? 0) * 100)}%

)} -
+ {stagedLoading ? ( + // Mid-load: nothing to load or abandon until it settles, so disable. - -
+ ) : ( +
+ + +
+ )} ) : modelSettingsDirty ? (
diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index f8a0eb579b..134878bdac 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -25,6 +25,7 @@ import { } from "../api/chat-api"; import { formatEta, formatRate } from "../utils/format-transfer"; import { + pendingSelectionMatches, readPersistedSpeculativeType, resolveToolsEnabledOnLoad, saveSpeculativeType, @@ -253,6 +254,7 @@ export function useChatModelRuntime() { displayName: string; isDownloaded?: boolean; isCachedLora?: boolean; + ggufVariant?: string | null; nativePathToken?: string | null; } | null>(null); const [loadToastDismissed, setLoadToastDismissed] = useState(false); @@ -399,29 +401,51 @@ export function useChatModelRuntime() { typeof selection === "string" ? false : selection.keepSpeculative ?? false; // Picking/loading any model abandons a staged (deferred) selection. // Before the early-returns below so even a no-op re-select clears the - // stage, and so the Load button unmounts on first click (no double-load). + // stage. const staged = useChatRuntimeStore.getState().pendingSelection; if (staged) { - // Loading a DIFFERENT model abandons this stage, so cancel its in-flight - // download. Loading the staged pick itself keeps it (that download feeds - // this load). - const loadingStagedPick = - staged.id === modelId && - (staged.ggufVariant ?? null) === (ggufVariant ?? null) && - (staged.nativePathToken ?? null) === (nativePathToken ?? null); - if (!loadingStagedPick) cancelStagedModelDownload(staged); - useChatRuntimeStore.getState().setPendingSelection(null); + // Loading a DIFFERENT model abandons this stage. Loading the staged pick + // ITSELF keeps it so the sidebar can show its load settings (context, KV + // cache, …) during the load. Cleared on success below; on failure it's + // left staged so the user can retry (see onLoadPendingModel's catch). + const loadingStagedPick = pendingSelectionMatches(staged, { + id: modelId, + ggufVariant, + nativePathToken, + }); + if (!loadingStagedPick) { + cancelStagedModelDownload(staged); + useChatRuntimeStore.getState().setPendingSelection(null); + } } const currentVariant = useChatRuntimeStore.getState().activeGgufVariant; if (!forceReload && (!modelId || (params.checkpoint === modelId && (ggufVariant ?? null) === (currentVariant ?? null)))) { return; } - // Prevent duplicate loads if already loading this model - if ( - loadingModelRef.current?.id === modelId && - (loadingModelRef.current?.nativePathToken ?? null) === (nativePathToken ?? null) - ) + // A load is already in flight. If it's this exact pick (id + GGUF variant + + // native path token), ignore the duplicate click. If it's a DIFFERENT model + // -- crucially including a different GGUF variant of the same repo, which the + // old id+token-only guard wrongly treated as a duplicate and silently + // no-op'd -- don't start a second concurrent load (the load path has no clean + // supersession) and don't silently swallow the request: surface it so the + // user knows to wait for, or cancel, the in-flight load. Centralized here so + // every entry point is covered, not just the staged Load button. + const inFlightLoad = loadingModelRef.current; + if (inFlightLoad) { + const loadingSamePick = + inFlightLoad.id === modelId && + (inFlightLoad.ggufVariant ?? null) === (ggufVariant ?? null) && + (inFlightLoad.nativePathToken ?? null) === (nativePathToken ?? null); + if (loadingSamePick) return; + const message = + "Another model is already loading. Wait for it to finish or cancel it first."; + setModelsError(message); + if (throwOnError) throw new Error(message); + toast.info("Another model is already loading", { + description: "Wait for it to finish or cancel it first.", + }); return; + } const explicitIsLora = typeof selection === "string" ? undefined : selection.isLora; @@ -475,6 +499,7 @@ export function useChatModelRuntime() { displayName, isDownloaded, isCachedLora, + ggufVariant: ggufVariant ?? null, nativePathToken: nativePathToken ?? null, }; setLoadingModel(loadInfo); @@ -509,6 +534,21 @@ export function useChatModelRuntime() { stateBeforeUnload.modelRequiresTrustRemoteCode; const previousActiveNativePathToken = stateBeforeUnload.activeNativePathToken; + // Snapshot the load settings at click time, before the awaits below + // (validation, the trust dialog, unload). For a staged Load these knobs + // stay editable and a sheet-close revert (abandonStagedModel) can fire + // mid-load; reading them live just before loadModel would let the load + // use post-click values. The model-switch speculative reset below + // updates this snapshot in lock-step so non-staged loads are unchanged. + const loadChatTemplateOverride = stateBeforeUnload.chatTemplateOverride; + const loadKvCacheDtype = stateBeforeUnload.kvCacheDtype; + const loadCustomContextLength = stateBeforeUnload.customContextLength; + const loadGgufContextLength = stateBeforeUnload.ggufContextLength; + const loadTensorParallel = stateBeforeUnload.tensorParallel; + const loadActivePresetSource = stateBeforeUnload.activePresetSource; + const loadActiveGgufVariant = stateBeforeUnload.activeGgufVariant; + let loadSpeculativeType = stateBeforeUnload.speculativeType; + let loadSpecDraftNMax = stateBeforeUnload.specDraftNMax; try { // Lightweight pre-flight validation: avoid unloading a working model // if the new identifier is clearly invalid (e.g. bad HF id / path). @@ -517,18 +557,17 @@ export function useChatModelRuntime() { : undefined; // Validate with the same effective context /load uses: a GGUF native // context can exceed maxSeqLength, so sizing on raw maxSeqLength could - // pass, unload, then have /load refuse it. Read pre-unload state; load - // recomputes its own value, so this leaves loading untouched. - const preUnloadState = useChatRuntimeStore.getState(); + // pass, unload, then have /load refuse it. Uses the click-time + // snapshot (same values loadModel uses below), so the two agree. const validateMaxSeqLength = resolveLoadMaxSeqLength({ modelId, ggufVariant, - customContextLength: preUnloadState.customContextLength, - ggufContextLength: preUnloadState.ggufContextLength, + customContextLength: loadCustomContextLength, + ggufContextLength: loadGgufContextLength, currentCheckpoint, - activeGgufVariant: preUnloadState.activeGgufVariant, + activeGgufVariant: loadActiveGgufVariant, maxSeqLength, - presetSource: preUnloadState.activePresetSource, + presetSource: loadActivePresetSource, }); const validation = await validateModel({ model_path: modelId, @@ -586,32 +625,23 @@ export function useChatModelRuntime() { specDraftNMax: null, loadedSpecDraftNMax: null, }); + loadSpeculativeType = persistedSpeculativeType; + loadSpecDraftNMax = null; } - const { - chatTemplateOverride, - kvCacheDtype, - customContextLength, - ggufContextLength, - speculativeType, - specDraftNMax, - tensorParallel, - activePresetSource, - activeGgufVariant, - } = useChatRuntimeStore.getState(); const effectiveMaxSeqLength = resolveLoadMaxSeqLength({ modelId, ggufVariant, isGguf, - customContextLength, - ggufContextLength, + customContextLength: loadCustomContextLength, + ggufContextLength: loadGgufContextLength, currentCheckpoint, - activeGgufVariant, + activeGgufVariant: loadActiveGgufVariant, maxSeqLength, - presetSource: activePresetSource, + presetSource: loadActivePresetSource, }); const effectiveChatTemplateOverride = - chatTemplateOverride?.trim() ? chatTemplateOverride : null; + loadChatTemplateOverride?.trim() ? loadChatTemplateOverride : null; const loadResponse = await loadModel({ model_path: modelId, nativePathLease: loadNativePathLease, @@ -623,10 +653,10 @@ export function useChatModelRuntime() { trust_remote_code: trustRemoteCode, approved_remote_code_fingerprint: approvedRemoteCodeFingerprint, chat_template_override: effectiveChatTemplateOverride, - cache_type_kv: kvCacheDtype, - speculative_type: speculativeType, - spec_draft_n_max: specDraftNMax, - tensor_parallel: tensorParallel, + cache_type_kv: loadKvCacheDtype, + speculative_type: loadSpeculativeType, + spec_draft_n_max: loadSpecDraftNMax, + tensor_parallel: loadTensorParallel, }); // If cancelled while loading, don't update UI to show @@ -636,7 +666,7 @@ export function useChatModelRuntime() { // The load applied this spec mode, so persist the user's standing // preference now (the requested intent, not the resolved echo; // saveSpeculativeType keeps only the universal auto/ngram/off). - saveSpeculativeType(speculativeType); + saveSpeculativeType(loadSpeculativeType); const currentParams = useChatRuntimeStore.getState().params; setParams( @@ -773,6 +803,25 @@ export function useChatModelRuntime() { } } await refresh({ signal: abortCtrl.signal }); + // A successful load owns the shared (pick-unscoped) settings fields, + // so any surviving stage is stale: the just-loaded pick itself, or a + // pick queued for a different model mid-load whose knobs this load + // overwrote. Drop it. Only a DIFFERENT pick's download needs + // cancelling; the loaded pick's is already consumed, and cancelling + // it inside its post-complete linger window would flicker its card. + const staleStage = useChatRuntimeStore.getState().pendingSelection; + if (staleStage) { + if ( + !pendingSelectionMatches(staleStage, { + id: modelId, + ggufVariant, + nativePathToken, + }) + ) { + cancelStagedModelDownload(staleStage); + } + useChatRuntimeStore.getState().setPendingSelection(null); + } } catch (error) { // Skip rollback if user cancelled -- model is already being unloaded. if (abortCtrl.signal.aborted) throw error; diff --git a/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts b/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts index d154b931d4..b11f496aa5 100644 --- a/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts +++ b/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts @@ -10,6 +10,7 @@ import type { DownloadJob } from "@/features/hub/download-manager/use-repo-downl import { fetchGgufContextLength } from "../api/chat-api"; import { isPendingGguf, + pendingSelectionMatches, useChatRuntimeStore, } from "../stores/chat-runtime-store"; @@ -54,15 +55,12 @@ export function useStagedModelPreparation(): DownloadJob { nativePathToken, }); // Apply only if the same model is still staged (the user may have switched - // picks or loaded/cancelled while the request was in flight). Native ids - // are display labels, not paths, so two files can share an id -- compare - // the path token too, or a stale response could land on the wrong pick. + // picks or loaded/cancelled while the request was in flight). const latest = useChatRuntimeStore.getState().pendingSelection; if ( - latest?.id === id && - (latest.ggufVariant ?? null) === (ggufVariant ?? null) && - (latest.nativePathToken ?? null) === (nativePathToken ?? null) && - contextLength != null + latest && + contextLength != null && + pendingSelectionMatches(latest, { id, ggufVariant, nativePathToken }) ) { setPendingSelection({ ...latest, contextLength }); } 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 e2d8701695..d8e578e790 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -453,6 +453,22 @@ export function isPendingGguf(pending: PendingModelSelection | null): boolean { return pending != null && hasGgufSource(pending); } +/** Whether `pending` refers to the same model as `pick` (id + GGUF variant + + * native path token, optionals null-normalized). Native ids are display labels + * that can collide, so the token must match too — id alone can land on the + * wrong file. */ +export function pendingSelectionMatches( + pending: PendingModelSelection | null, + pick: { id: string; ggufVariant?: string | null; nativePathToken?: string | null }, +): boolean { + return ( + pending != null && + pending.id === pick.id && + (pending.ggufVariant ?? null) === (pick.ggufVariant ?? null) && + (pending.nativePathToken ?? null) === (pick.nativePathToken ?? null) + ); +} + type ChatRuntimeStore = { settingsHydrated: boolean; params: InferenceParams; @@ -1455,7 +1471,10 @@ export const useChatRuntimeStore = create((set, get) => ({ set({ loadOnSelection }); }, setPendingSelection: (pendingSelection) => set({ pendingSelection }), - stageModel: (selection) => + stageModel: (selection) => { + // Refuse staging mid-load: post-load cleanup would silently drop the queued + // pick. stageOrLoad toasts first for callers that can. + if (get().modelLoading) return; set((s) => { if ( s.pendingSelection && @@ -1475,7 +1494,8 @@ export const useChatRuntimeStore = create((set, get) => ({ speculativeType: readPersistedSpeculativeType(), specDraftNMax: null, }; - }), + }); + }, abandonStagedModel: () => { const { pendingSelection } = get(); if (!pendingSelection) return; From cba73457df6ae2ba8ddcd004b68d2ee23efee290 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 21 Jun 2026 22:43:31 -0700 Subject: [PATCH 002/306] Studio: self-heal unsloth namespace shadows; clearer failed-load messages (#6532) * Studio: self-heal unsloth namespace-package shadows in all subprocess workers A directory named `unsloth` (or `unsloth_zoo`) without an __init__.py on PYTHONPATH/sys.path, a stray source checkout or a polluted PYTHONPATH, makes `import unsloth` resolve to an empty namespace package, so a worker's `from unsloth import FastLanguageModel` dies with a cryptic "cannot import name ... (unknown location)". The LLM training path already recovered from this via `_ensure_real_packages` in trainer.py (PR #6269), but the inference, export, and embedding-training subprocesses imported Unsloth directly with no guard. Extract that helper into a shared, dependency-free core/import_guards.py and call it before the Unsloth import in every subprocess: it drops the offending sys.path entries, imports the real packages (unsloth before unsloth_zoo so the pre-zoo GPU fixes run), then restores sys.path. trainer.py now imports the shared helper instead of its local copy. Covers both unsloth and unsloth_zoo and both namespace origin forms (None and "namespace"). The existing PR #6269 test now exercises the shared helper. * Studio: distinguish a failed model load from no model in the attach gates A failed load never sets the checkpoint, so the image and audio attach gates fell through to "Load a model before adding images/audio", which reads as if the user simply forgot to pick a model rather than that the load errored. Add a dedicated lastModelLoadError to the chat runtime store, set only when an actual load attempt fails (not on refresh, list, status, or unload errors, which keep using modelsError) and cleared when the next load starts. The image gate (all three call sites) and the audio gate now use it to report a failed load and point at the server logs, while still blocking in exactly the same cases. * Tighten namespace-shadow guard and load-error comments --- studio/backend/core/export/worker.py | 5 ++ studio/backend/core/import_guards.py | 53 ++++++++++++++++++ studio/backend/core/inference/worker.py | 5 ++ studio/backend/core/training/trainer.py | 54 +------------------ studio/backend/core/training/worker.py | 4 ++ .../test_namespace_shadow_guard_pr6269.py | 25 +++++---- .../src/features/chat/api/chat-adapter.ts | 1 + .../features/chat/audio-attachment-adapter.ts | 5 +- .../chat/hooks/use-chat-model-runtime.ts | 6 +++ .../src/features/chat/runtime-provider.tsx | 1 + .../src/features/chat/shared-composer.tsx | 2 + .../chat/stores/chat-runtime-store.ts | 6 +++ .../chat/utils/image-input-support.ts | 11 +++- 13 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 studio/backend/core/import_guards.py diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py index 7216221f44..f03dcfa41a 100644 --- a/studio/backend/core/export/worker.py +++ b/studio/backend/core/export/worker.py @@ -506,6 +506,11 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None if backend_path not in sys.path: sys.path.insert(0, backend_path) + # Recover from any namespace-package shadow before importing Unsloth. + from core.import_guards import ensure_real_packages + + ensure_real_packages("unsloth_zoo", "unsloth") + from core.export.export import ExportBackend import transformers diff --git a/studio/backend/core/import_guards.py b/studio/backend/core/import_guards.py new file mode 100644 index 0000000000..5b85a96cd2 --- /dev/null +++ b/studio/backend/core/import_guards.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Recover `unsloth`/`unsloth_zoo` from a namespace-package shadow. Stdlib-only.""" + +from __future__ import annotations + +import os +import sys + + +def ensure_real_packages(*names: str) -> None: + """Drop sys.path entries where a bare `/` dir (no __init__.py) shadows + the installed package as a namespace, import the real packages, restore + sys.path. No-op without a shadow. Pass dependency-first (e.g. "unsloth_zoo", + "unsloth"); imports run dependency-last.""" + import importlib + import importlib.util + + bad: set = set() + shadowed: list = [] + for name in names: + try: + spec = importlib.util.find_spec(name) + except (ImportError, ValueError, AttributeError): + spec = None + # real package -> spec.origin is its __init__; namespace shadow -> None/"namespace" + if spec is None or spec.origin not in (None, "namespace"): + continue + dirs = {os.path.realpath(d) for d in (spec.submodule_search_locations or [])} + if not dirs: + continue + shadowed.append(name) + for entry in sys.path: + pkg = os.path.join(entry or os.getcwd(), name) + if os.path.realpath(pkg) in dirs and not os.path.isfile( + os.path.join(pkg, "__init__.py") + ): + bad.add(entry) + if not bad: + return + saved = list(sys.path) + sys.path[:] = [e for e in sys.path if e not in bad] + for name in shadowed: + for cached in [m for m in list(sys.modules) if m == name or m.startswith(name + ".")]: + del sys.modules[cached] + try: + importlib.invalidate_caches() + # import unsloth before unsloth_zoo: unsloth.__init__ runs GPU/bnb fixes zoo relies on + for name in reversed(names): + importlib.import_module(name) + finally: + sys.path[:] = saved diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index 44c0f5b3be..eb31ec1062 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -716,6 +716,11 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf _ensure_backend_on_path() + # Recover from any namespace-package shadow before importing Unsloth. + from core.import_guards import ensure_real_packages + + ensure_real_packages("unsloth_zoo", "unsloth") + from core.inference.inference import InferenceBackend import transformers diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 0c14061be6..bdf2a6f030 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -46,58 +46,8 @@ if hasattr(torch._dynamo.config, "recompile_limit"): torch._dynamo.config.recompile_limit = 64 -def _ensure_real_packages(*names: str) -> None: - """Stop `import ` from binding to a namespace-package shadow. - - A directory named like the package but missing __init__.py on sys.path (a - stray checkout, a partial clone, or a polluted PYTHONPATH) makes the path - finder return a namespace package, so `from unsloth import FastLanguageModel` - dies with "cannot import name ... (unknown location)". A normal - site-packages install always wins, so only source/editable installs are - exposed. Drop the offending entries, import the real packages, then restore - sys.path so other modules on those entries keep importing. - """ - import importlib - import importlib.util - - bad: set = set() - shadowed: list = [] - for name in names: - try: - spec = importlib.util.find_spec(name) - except (ImportError, ValueError, AttributeError): - spec = None - # a real package exposes its __init__ via spec.origin; a namespace - # shadow has origin None/"namespace" and only search locations - if spec is None or spec.origin not in (None, "namespace"): - continue - dirs = {os.path.realpath(d) for d in (spec.submodule_search_locations or [])} - if not dirs: - continue - shadowed.append(name) - for entry in sys.path: - pkg = os.path.join(entry or os.getcwd(), name) - if os.path.realpath(pkg) in dirs and not os.path.isfile( - os.path.join(pkg, "__init__.py") - ): - bad.add(entry) - if not bad: - return - saved = list(sys.path) - sys.path[:] = [e for e in sys.path if e not in bad] - for name in shadowed: - for cached in [m for m in list(sys.modules) if m == name or m.startswith(name + ".")]: - del sys.modules[cached] - try: - importlib.invalidate_caches() - # Import unsloth before unsloth_zoo (names are dependency-first): - # unsloth.__init__ runs ROCm/Windows bnb fixes before it imports zoo, - # so importing zoo first here would skip them. Repeat import is a no-op. - for name in reversed(names): - importlib.import_module(name) - finally: - sys.path[:] = saved - +# Drop any unsloth/unsloth_zoo namespace-package shadow before importing them. +from core.import_guards import ensure_real_packages as _ensure_real_packages _ensure_real_packages("unsloth_zoo", "unsloth") from unsloth import FastLanguageModel, FastVisionModel, is_bfloat16_supported diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 6374589930..4fbdbf21ad 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -3156,6 +3156,10 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) -> # ── 1. Import embedding-specific libraries ── _send_status(event_queue, "Importing embedding libraries...") try: + # Recover from a namespace-package shadow (embedding imports unsloth directly). + from core.import_guards import ensure_real_packages + + ensure_real_packages("unsloth_zoo", "unsloth") from unsloth import FastSentenceTransformer, is_bfloat16_supported from sentence_transformers import ( SentenceTransformerTrainer, diff --git a/studio/backend/tests/test_namespace_shadow_guard_pr6269.py b/studio/backend/tests/test_namespace_shadow_guard_pr6269.py index 3ed6636bda..f77345293e 100644 --- a/studio/backend/tests/test_namespace_shadow_guard_pr6269.py +++ b/studio/backend/tests/test_namespace_shadow_guard_pr6269.py @@ -1,15 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Verification tests for PR #6269 (training-worker namespace-shadow guard). +"""Verification tests for PR #6269 (namespace-shadow guard). -`_ensure_real_packages` (core/training/trainer.py) drops namespace-package +`ensure_real_packages` (core/import_guards.py) drops namespace-package shadow dirs (a `unsloth`/`unsloth_zoo` dir with no __init__.py on sys.path) before `from unsloth import ...`. Order matters: `unsloth.__init__` runs its ROCm/Windows bnb fixes before importing unsloth_zoo, so the guard must import unsloth first. Each test runs the real guard (ast-extracted from source, no GPU/torch) in a subprocess, with fake packages reachable only via a meta path finder to mimic an editable/PEP 660 install where the shadow wins. + +Originally defined in core/training/trainer.py; extracted to the shared +core/import_guards.py so the inference, export and embedding workers reuse it. """ import json @@ -21,7 +24,7 @@ from pathlib import Path import pytest -TRAINER_PY = Path(__file__).resolve().parents[1] / "core" / "training" / "trainer.py" +GUARD_PY = Path(__file__).resolve().parents[1] / "core" / "import_guards.py" # ── fake package bodies ────────────────────────────────────────────── @@ -62,17 +65,17 @@ _DRIVER = textwrap.dedent( cfg = json.load(open(sys.argv[1])) - # Extract the real _ensure_real_packages from trainer.py source without - # importing the heavy module or its `from unsloth import ...` line. - src = open(cfg["trainer_py"]).read() + # Extract the real ensure_real_packages from import_guards.py source + # without importing the heavy module or its `from unsloth import ...` line. + src = open(cfg["guard_py"]).read() tree = ast.parse(src) fn = next(n for n in tree.body - if isinstance(n, ast.FunctionDef) and n.name == "_ensure_real_packages") + if isinstance(n, ast.FunctionDef) and n.name == "ensure_real_packages") mod = ast.Module(body=[fn], type_ignores=[]) ast.fix_missing_locations(mod) ns = {"os": os, "sys": sys} - exec(compile(mod, cfg["trainer_py"], "exec"), ns) - _ensure_real_packages = ns["_ensure_real_packages"] + exec(compile(mod, cfg["guard_py"], "exec"), ns) + _ensure_real_packages = ns["ensure_real_packages"] # Under -S site-packages is off, so a shadow root placed first wins the # path finder; the real packages come only from the meta finder below. @@ -148,7 +151,7 @@ def _run( shadow_roots, real: bool, names = ("unsloth_zoo", "unsloth"), - trainer_py: Path = TRAINER_PY, + guard_py: Path = GUARD_PY, raise_on_invalidate: bool = False, ): order_file = tmp_path / "order.txt" @@ -159,7 +162,7 @@ def _run( _make_real_pkg(real_root) cfg = { - "trainer_py": str(trainer_py), + "guard_py": str(guard_py), "shadow_roots": [str(r) for r in shadow_roots], "real_root": str(real_root) if real else None, "names": list(names), diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index a9097527be..a58cdd94d7 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1926,6 +1926,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { externalModelLabel: externalSelection?.modelId ?? null, loadedIsMultimodal: runtime.loadedIsMultimodal, modelLoaded: !!params.checkpoint && !runtime.modelLoading, + loadError: runtime.lastModelLoadError, }); if (imageGateReason) { toast.error(imageGateReason); diff --git a/studio/frontend/src/features/chat/audio-attachment-adapter.ts b/studio/frontend/src/features/chat/audio-attachment-adapter.ts index 148f025e57..99a26ed178 100644 --- a/studio/frontend/src/features/chat/audio-attachment-adapter.ts +++ b/studio/frontend/src/features/chat/audio-attachment-adapter.ts @@ -39,7 +39,10 @@ export class AudioAttachmentAdapter implements AttachmentAdapter { const modelLoaded = !!checkpoint && !state.modelLoading; let unavailableReason: string | null = null; if (!modelLoaded) { - unavailableReason = "Load a model before adding audio files."; + // Mirror the image gate: flag a failed load vs "no model picked". + unavailableReason = state.lastModelLoadError + ? "The last model failed to load. Check the server logs, then load a model before adding audio files." + : "Load a model before adding audio files."; } else if (!activeModel?.hasAudioInput) { const label = activeModel?.name || checkpoint || "Current model"; unavailableReason = `${label} cannot accept audio. Load an audio-input model before attaching audio files.`; diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 134878bdac..bbf106400c 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -246,6 +246,9 @@ export function useChatModelRuntime() { const setLoras = useChatRuntimeStore((state) => state.setLoras); const setParams = useChatRuntimeStore((state) => state.setParams); const setModelsError = useChatRuntimeStore((state) => state.setModelsError); + const setLastModelLoadError = useChatRuntimeStore( + (state) => state.setLastModelLoadError, + ); const setCheckpoint = useChatRuntimeStore((state) => state.setCheckpoint); const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint); @@ -493,6 +496,7 @@ export function useChatModelRuntime() { .filter(Boolean) .join(" "); setModelsError(null); + setLastModelLoadError(null); // clear prior failed-load marker setLoadToastDismissedState(false); const loadInfo = { id: modelId, @@ -1184,6 +1188,7 @@ export function useChatModelRuntime() { const message = error instanceof Error ? error.message : "Failed to load model"; setModelsError(message); + setLastModelLoadError(message); // load-specific failure for the attach gates if (throwOnError) { throw error instanceof Error ? error : new Error(message); } @@ -1199,6 +1204,7 @@ export function useChatModelRuntime() { resetLoadingUi, setLoadToastDismissedState, setModelsError, + setLastModelLoadError, setParams, ], ); diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index d08d5ef6af..fbaa4400ed 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -114,6 +114,7 @@ class VisionImageAdapter implements AttachmentAdapter { externalModelLabel, loadedIsMultimodal: state.loadedIsMultimodal, modelLoaded, + loadError: state.lastModelLoadError, }); if (unavailableReason) { toast.error(unavailableReason); diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 2adc3ae25a..f35ccc6149 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -485,6 +485,7 @@ export function SharedComposer({ const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); + const lastModelLoadError = useChatRuntimeStore((s) => s.lastModelLoadError); const loadedIsMultimodal = useChatRuntimeStore((s) => s.loadedIsMultimodal); const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn); @@ -566,6 +567,7 @@ export function SharedComposer({ externalModelLabel: externalSelection?.modelId ?? null, loadedIsMultimodal, modelLoaded, + loadError: lastModelLoadError, }); const isCompareMode = Boolean(model1?.id || model2?.id); // Attach-time gate. Compare mode defers to send: the catalog can lag a 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 d8e578e790..2ec741b1bf 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -482,6 +482,9 @@ type ChatRuntimeStore = { autoTitle: boolean; hfToken: string; modelsError: string | null; + // Set only when a LOAD fails (not refresh/list/unload, which use modelsError); + // lets the attach gates flag a failed load vs "no model picked". + lastModelLoadError: string | null; activeGgufVariant: string | null; ggufContextLength: number | null; ggufMaxContextLength: number | null; @@ -660,6 +663,7 @@ type ChatRuntimeStore = { setAutoTitle: (enabled: boolean) => void; setHfToken: (token: string) => void; setModelsError: (error: string | null) => void; + setLastModelLoadError: (error: string | null) => void; setCheckpoint: (modelId: string, ggufVariant?: string | null) => void; setActiveThreadId: (threadId: string | null) => void; setActiveProjectId: (projectId: string | null) => void; @@ -964,6 +968,7 @@ export const useChatRuntimeStore = create((set, get) => ({ autoTitle: false, hfToken: loadString(HF_TOKEN_KEY, ""), modelsError: null, + lastModelLoadError: null, activeGgufVariant: null, ggufContextLength: null, ggufMaxContextLength: null, @@ -1159,6 +1164,7 @@ export const useChatRuntimeStore = create((set, get) => ({ notifyHfTokenChanged(hfToken); }, setModelsError: (modelsError) => set({ modelsError }), + setLastModelLoadError: (lastModelLoadError) => set({ lastModelLoadError }), setCheckpoint: (modelId, ggufVariant) => set((state) => { // Persist external selections so they survive a refresh. Local ids are diff --git a/studio/frontend/src/features/chat/utils/image-input-support.ts b/studio/frontend/src/features/chat/utils/image-input-support.ts index d1ea31fc76..9ce6de39ba 100644 --- a/studio/frontend/src/features/chat/utils/image-input-support.ts +++ b/studio/frontend/src/features/chat/utils/image-input-support.ts @@ -10,6 +10,7 @@ export function getImageInputUnavailableReason({ externalModelLabel, loadedIsMultimodal, modelLoaded, + loadError, }: { activeModel?: ChatModelSummary; isExternalModel: boolean; @@ -21,6 +22,8 @@ export function getImageInputUnavailableReason({ externalModelLabel?: string | null; loadedIsMultimodal: boolean; modelLoaded: boolean; + // Runtime lastModelLoadError; lets the no-model branch flag a failed load. + loadError?: string | null; }): string | null { if (isExternalModel) { const explicitlyNonVision = @@ -39,7 +42,13 @@ export function getImageInputUnavailableReason({ } return null; } - if (!modelLoaded) return "Load a model before adding images."; + if (!modelLoaded) { + // Distinguish a failed load from "no model picked yet". + if (loadError) { + return "The last model failed to load. Check the server logs, then load a model before adding images."; + } + return "Load a model before adding images."; + } // loadedIsMultimodal is true for vision OR audio; that one flag can't tell // them apart, so only block when activeModel confirms audio-only (audio // capability set AND isVision === false). Otherwise trust the load From d77845ebc00865c828dd990acbaa90831a142942 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 01:10:49 -0700 Subject: [PATCH 003/306] Update studio root-resilience tests for the inference-backend refactor (#6490) (#6553) * Update studio root-resilience tests for the inference-backend refactor #6490 moved the studio_root() probe and its (ImportError, OSError, ValueError) handler out of _find_llama_server_binary / _kill_orphaned_servers into the shared _resolved_studio_root_and_is_legacy() classifier, and switched the WSL ROCm lib-dir ordering to lib_dirs.extend(_wsl_system_rocm_lib_dirs()). These source-introspection tests still asserted the old inline structure, so they fail on main (surfaced by any PR that trips the Repo tests path filter, e.g. the Windows installer PRs). Point them at the new structure and assert the defense in its new home; no runtime change. * Address review: qualify the classifier call and harden helper-body extraction Assert the callers invoke LlamaCppBackend._resolved_studio_root_and_is_legacy() through the class namespace (more precise than the bare name), and end the helper-source slice at the next sibling def/decorator at the same indent instead of the literal @staticmethod string, so a future docstring that mentions a decorator can't truncate the helper mid-body and break exec(). --------- Co-authored-by: danielhanchen --- tests/studio/install/test_rocm_support.py | 2 +- tests/test_studio_install_workspace_guard.py | 20 ++++-- tests/test_studio_root_resilience.py | 74 ++++++++++++++------ 3 files changed, 65 insertions(+), 31 deletions(-) diff --git a/tests/studio/install/test_rocm_support.py b/tests/studio/install/test_rocm_support.py index 8e0b5d1b7f..6915269cec 100644 --- a/tests/studio/install/test_rocm_support.py +++ b/tests/studio/install/test_rocm_support.py @@ -3375,7 +3375,7 @@ class TestLlamaCppRuntimeWslOrdering: def test_prepends_before_binary_dir(self): source = _LLAMA_CPP_PATH.read_text(encoding = "utf-8") - idx_helper = source.find("for _wsl_rocm in _wsl_system_rocm_lib_dirs()") + idx_helper = source.find("lib_dirs.extend(_wsl_system_rocm_lib_dirs())") idx_binary = source.find("lib_dirs.append(binary_dir)") assert idx_helper != -1 and idx_binary != -1 assert idx_helper < idx_binary diff --git a/tests/test_studio_install_workspace_guard.py b/tests/test_studio_install_workspace_guard.py index 68c1a2db50..89836cfb0d 100644 --- a/tests/test_studio_install_workspace_guard.py +++ b/tests/test_studio_install_workspace_guard.py @@ -771,7 +771,10 @@ def test_main_py_read_studio_install_id_validates_hex_and_handles_missing(tmp_pa def test_llama_cpp_search_roots_handles_studio_root_oserror(): - """_find_llama_server_binary must catch (ImportError, OSError, ValueError) from studio_root() like its sibling.""" + """Root resolution must catch (ImportError, OSError, ValueError) from studio_root(). + Discovery (_find_llama_server_binary) and cleanup (_kill_orphaned_servers) both + delegate to the shared _resolved_studio_root_and_is_legacy() classifier, which + holds the handler so the two never disagree on which root is legacy.""" llama_cpp = ( REPO_ROOT / "studio" / "backend" / "core" / "inference" / "llama_cpp.py" ).read_text() @@ -783,12 +786,15 @@ def test_llama_cpp_search_roots_handles_studio_root_oserror(): nxt = llama_cpp.find(f"\n{indent}def ", start + 1) return llama_cpp[start : nxt if nxt != -1 else len(llama_cpp)] - assert "except (ImportError, OSError, ValueError):" in _method_body( - "_find_llama_server_binary" - ), "_find_llama_server_binary must catch (ImportError, OSError, ValueError) from studio_root()" - assert "except (ImportError, OSError, ValueError):" in _method_body( - "_kill_orphaned_servers" - ), "sibling _kill_orphaned_servers must keep its (ImportError, OSError, ValueError) handler" + assert ( + "except (ImportError, OSError, ValueError):" + in _method_body("_resolved_studio_root_and_is_legacy") + ), "_resolved_studio_root_and_is_legacy must catch (ImportError, OSError, ValueError) from studio_root()" + # Both callers must route through the shared classifier so neither crashes. + for caller in ("_find_llama_server_binary", "_kill_orphaned_servers"): + assert "LlamaCppBackend._resolved_studio_root_and_is_legacy()" in _method_body( + caller + ), f"{caller} must resolve the install root via the shared classifier" def test_install_sh_install_id_survives_symlinked_studio_home(tmp_path): diff --git a/tests/test_studio_root_resilience.py b/tests/test_studio_root_resilience.py index 5b835f770b..0dfb826376 100644 --- a/tests/test_studio_root_resilience.py +++ b/tests/test_studio_root_resilience.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib.util -import re import sys import textwrap from pathlib import Path @@ -53,36 +52,55 @@ def test_studio_root_does_not_crash_on_permission_error(tmp_path, monkeypatch): assert result == Path.home() / ".unsloth" / "studio" +def _method_body(src: str, name: str) -> str: + """Whole method body (def to next sibling def at the same indent).""" + start = src.index(f"def {name}") + indent = " " * (start - src.rfind("\n", 0, start) - 1) + nxt = src.find(f"\n{indent}def ", start + 1) + return src[start : nxt if nxt != -1 else len(src)] + + def test_kill_orphan_catches_oserror_from_studio_root(): - """_kill_orphaned_servers must catch (ImportError, OSError, ValueError) on the studio_root() probe.""" + """Cleanup must not crash when studio_root() raises. _kill_orphaned_servers + resolves the install root through the shared _resolved_studio_root_and_is_legacy() + classifier, which swallows (ImportError, OSError, ValueError) on the probe.""" src = LLAMA_CPP.read_text() - fn_start = src.index("def _kill_orphaned_servers") - fn_body = src[fn_start : fn_start + 4000] - # The studio_root() probe imports as `_sr` and assigns `_resolved_sr = _sr()`. - probe_idx = fn_body.index("storage_roots import studio_root as _sr") - # The matching except is the next one after the inner resolve() block. - after = fn_body[probe_idx:] - # Skip the inner `except (OSError, ValueError):` that wraps resolve(). - inner_idx = after.index("except (OSError, ValueError):") - after_inner = after[inner_idx + len("except (OSError, ValueError):") :] - outer_match = re.search(r"except\s*\(?[^)]*?\)?:", after_inner) - assert outer_match, "outer except for studio_root probe missing" - clause = outer_match.group(0) - assert ( - "OSError" in clause and "ValueError" in clause - ), f"_kill_orphaned_servers studio_root probe catch too narrow: {clause!r}" + # Cleanup delegates to the shared classifier rather than importing studio_root inline. + assert "LlamaCppBackend._resolved_studio_root_and_is_legacy()" in _method_body( + src, "_kill_orphaned_servers" + ), "_kill_orphaned_servers must resolve the root via _resolved_studio_root_and_is_legacy()" + # The shared classifier catches both the resolve() failure and the outer studio_root() probe. + classifier = _method_body(src, "_resolved_studio_root_and_is_legacy") + assert "studio_root as _sr" in classifier, "classifier must probe studio_root()" + assert "except (OSError, ValueError):" in classifier, "inner resolve() probe must be guarded" + assert "except (ImportError, OSError, ValueError):" in classifier, ( + "_resolved_studio_root_and_is_legacy must catch (ImportError, OSError, ValueError) " + "from studio_root()" + ) def _exec_search_roots_block( home: Path, studio_root_value: Path, resolve_raises: bool ) -> list[Path]: - """Extract and run _find_llama_server_binary's env-mode search_roots block with controlled inputs.""" + """Run _find_llama_server_binary's search_roots derivation -- plus the shared + _resolved_studio_root_and_is_legacy() classifier it delegates to -- with a + controlled studio_root() and resolve(), without importing the heavy module.""" src = LLAMA_CPP.read_text() + # Shared root classifier (holds the defensive try/except for studio_root()). + # End the slice at the next sibling def/decorator at the same indent rather + # than the literal "@staticmethod" string, so a future docstring mentioning a + # decorator can't truncate the helper mid-body and break exec(). + helper_start = src.index("def _resolved_studio_root_and_is_legacy") + indent = " " * (helper_start - src.rfind("\n", 0, helper_start) - 1) + nxt_def = src.find(f"\n{indent}def ", helper_start + 1) + nxt_dec = src.find(f"\n{indent}@", helper_start + 1) + sibling = [idx for idx in (nxt_def, nxt_dec) if idx != -1] + helper_end = min(sibling) if sibling else len(src) + helper = textwrap.dedent(src[helper_start:helper_end]) + # search_roots derivation inside _find_llama_server_binary (delegates to the classifier). block_start = src.index('legacy_llama = Path.home() / ".unsloth" / "llama.cpp"') - block_end = src.index("_seen_roots: set[str]", block_start) - raw = src[block_start:block_end] - indent = " " * 8 - block = textwrap.dedent(indent + raw) + block_end = src.index("for unsloth_home in search_roots:", block_start) + block = textwrap.dedent(" " * 8 + src[block_start:block_end]) fake_module = type(sys)("fake_storage_roots") fake_module.studio_root = lambda: studio_root_value sys.modules["utils.paths.storage_roots"] = fake_module @@ -99,7 +117,17 @@ def _exec_search_roots_block( mock.patch.object(Path, "resolve", _resolve), ): ns: dict = {"Path": Path} - exec(block, ns) # noqa: S102 + exec(helper, ns) # noqa: S102 -- defines _resolved_studio_root_and_is_legacy + ns["LlamaCppBackend"] = type( + "LlamaCppBackend", + (), + { + "_resolved_studio_root_and_is_legacy": staticmethod( + ns["_resolved_studio_root_and_is_legacy"] + ) + }, + ) + exec(block, ns) # noqa: S102 -- defines search_roots return ns["search_roots"] finally: sys.modules.pop("utils.paths.storage_roots", None) From a41b8c7a4418e564432630ba2bdc86b9c09a51e3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 01:11:09 -0700 Subject: [PATCH 004/306] Make Visual Studio + CMake optional on Windows (prebuilt llama.cpp needs no build tools) (#6499) * studio/setup.ps1: complete Visual Studio 2026 support for the CUDA llama.cpp build Builds on #6038 (VS 2026 / v18 detection). Once the generator is detected as Visual Studio 18 2026, two things still broke the CUDA llama.cpp build: - the CUDA to VS MSBuild integration copied the CUDA .targets into a hardcoded VC\v170 (VS 2022) BuildCustomizations folder, so a VS 2026 (v180) toolchain saw no CUDA toolset and cmake failed with "No CUDA toolset found". - cmake was installed with no version check, but the "Visual Studio 18 2026" generator requires CMake 4.2+. This adds Get-VcBuildCustomizationsDir (derives v160/v170/v180 from the detected generator, falls back to v170), a CMake 4.2 guard for the VS 2026 generator (upgrades via winget once, else fails with a clear message), and routes both the copy target and the failure hint through the derived path. No behavior change for VS 2022/2019/2017: the folder resolves to v170 and the guard is skipped. Adds windows-latest Pester unit tests (tests/studio_setup_ps1) plus a workflow that runs them. * Address review: make VS 2026 self-contained + gate CMake guard to source build - Find-VsBuildTools now detects VS 2026: vswhere catalog_productLineVersion 2026 -> "Visual Studio 18 2026", and the filesystem scan covers the "18"/"2026" dirs (incl. non-standard editions like Preview). Adapted from #6038 by @LeoBorcherding, so the v180 BuildCustomizations path and the CMake guard are actually reachable on a VS 2026-only host. - Move the CMake 4.2 guard out of Phase 1 into the committed-source-build branch. The preferred prebuilt llama.cpp path never reaches it, so a VS 2026 host on CMake < 4.2 is no longer blocked from using the prebuilt. - winget upgrade -> install fallback when the on-PATH cmake is not the Kitware winget package, and log winget failures instead of swallowing them. - Add a windows-latest Find-VsBuildTools VS 2026 discovery regression test. * tests(vs2026): define New-FakeVsTree in BeforeAll so It blocks can see it The Find-VsBuildTools discovery tests are Windows-only (-Skip on non-Windows), so they first ran on the windows-latest Pester job, where New-FakeVsTree raised CommandNotFoundException: it was defined in the Describe body, which Pester 5 executes only during discovery, so the function did not persist into the run-phase It scope. Move it into a BeforeAll block (which runs in the run phase and is visible to the It blocks). No production code change. * Address review: probe cmake generator support, fall back to older VS, fix cmake PATH after winget The VS 2026 CMake guard previously gated only on the cmake version (>= 4.2) and hard-failed otherwise. Review on #6473 raised three real gaps: - A VS-bundled cmake below 4.2 can still drive the VS 2026 generator. Probe cmake --help (Test-CmakeListsGenerator / Test-CmakeCanDriveGenerator) and accept it when the generator is advertised, not just on the version floor. - After winget upgrade/install, an older cmake earlier on PATH kept being resolved. Add-DefaultCmakeToPath prepends the default install dir so the new cmake wins before re-probing. - When cmake cannot drive VS 2026 but an older Visual Studio (2022/2019/2017) is installed and usable, fall back to it (Get-FallbackVsGenerator) instead of hard-failing, preserving the pre-VS-2026 build path. Tests mock the cmake command rather than dropping a shim on PATH: PowerShell caches its application-path table, so a real cmake on the runner (present on windows-latest) wins over a PATH shim. A function mock is resolved first and is cache-proof cross-platform. * Detect VS installed under the Preview edition dir for older versions Find-VsBuildTools already scans every subdir for VS 2026, but the older-version (2017/2019/2022) filesystem fallback and Get-FallbackVsGenerator only checked BuildTools/Community/Professional/Enterprise. A Preview-channel install lives under a 'Preview' edition folder, so it was missed when vswhere was also unavailable. Add 'Preview' to both edition lists and guard each with a Windows Pester test. * Add real-VS integration matrix: detect actual VS 2022 and VS 2026 in parallel The unit tests validate VS detection logic with mocked vswhere and fake install trees (all five versions). This adds a parallel integration job that runs the real Find-VsBuildTools / Get-VcBuildCustomizationsDir against the Visual Studio actually preinstalled on GitHub-hosted runners: - windows-2022 -> real Visual Studio 2022, expect generator v170 - windows-2025-vs2026 -> real Visual Studio 2026, expect generator v180 It asserts our detection matches the real install, the install path exists, the derived toolset matches, and that the derived v-number is a real folder on the VS install. VS 2017/2019/2015 are retired from hosted images, so only 2022 and 2026 can be exercised against a genuine install; the rest stay covered by the mocks. * Detect VS 2026 via vswhere: it reports productLineVersion '18', not '2026' Real-VS CI on the windows-2025-vs2026 runner showed vswhere reports catalog_productLineVersion='18' (the internal major) for Visual Studio 2026, not the marketing year '2026' that VS <= 2022 report. The vswhere map only had '2026', so on a real VS 2026 host the vswhere branch returned null and detection survived only via the filesystem scan (Source='filesystem'); a VS 2026 installed outside the default Program Files location would not be found at all. Extract a pure Resolve-VsGeneratorFromLabel that accepts both the year and the internal-major form ('18'/'17'/'16'/'15' as well as '2026'/'2022'/'2019'/'2017') and use it for both the vswhere and filesystem branches. Add pure unit tests (cross-platform) for the mapping, including the '18' -> VS 2026 case. * ci: dot-source Resolve-VsGeneratorFromLabel in the real-VS integration job Find-VsBuildTools now calls Resolve-VsGeneratorFromLabel, so the integration step must extract it too; without it the job failed with the helper not recognized. * Defer Visual Studio + CMake to the llama.cpp source build (prebuilt path needs no build tools) The Windows installer required Visual Studio Build Tools and CMake eagerly in Phase 1 (winget install + exit 1 if absent), before the llama.cpp prebuilt-vs- source decision. But the preferred path downloads a prebuilt llama.cpp (no compiler), the backend only shells out to the prebuilt llama-server.exe, and PyTorch is pip wheels -- so VS and CMake are only needed for the from-source build last resort. The eager requirement forced every Windows user to install multi-GB Visual Studio + CMake they never use, or the installer failed. Change (mirrors the already-lazy Resolve-CudaToolkit / OpenSSL): - Phase 1c/1d now only DETECT cmake / VS and log; they never winget-install or exit. The prebuilt install runs zero build-tool installs and is unblocked on hosts without build tools. - New Ensure-BuildToolsForLlamaSourceBuild installs CMake (best effort) + VS (hard requirement, exit 1 with the existing guidance if it cannot be found), called only when a source build is actually committed, before Resolve-CudaToolkit. git stays eager (pip needs it for git+ deps). Tests: - Pester: the early probe (Find-VsBuildTools) returns null without exiting when no VS is present; Ensure-BuildToolsForLlamaSourceBuild no-ops when VS is already detected. - New studio-windows-no-vs-smoke.yml: Job A renames Visual Studio + vswhere away and hides cmake, runs the real install.ps1 --local --no-torch, and asserts the prebuilt llama.cpp installed (no source-build fallback, no VS/CMake install), PyTorch CPU imports, the backend is healthy, and a /v1/chat/completions inference returns a reply -- all with no Visual Studio. Job B confirms the GPU CUDA prebuilt is available and the resolver runs without VS. * Fix VS 2026 CUDA source build ordering and fallback VS discovery Same fix as on the stacked base branch (studio-vs2026-cuda-msbuild): - Move Resolve-CudaToolkit below the CMake gate/fallback in the source build path. It copies the CUDA MSBuild .targets into the current VS generator's BuildCustomizations folder, so running it before a VS 2026 to older-VS fallback left the .targets under v180 while cmake configured v170 ("No CUDA toolset found"). It now runs after the final generator is selected. - Get-FallbackVsGenerator now queries vswhere first, matching Find-VsBuildTools, so a VS installed outside the default Program Files roots is found instead of failing with a hard exit. - Add Pester regression tests: the source build resolves CUDA after the fallback, and the fallback queries vswhere. * Ensure the Visual C++ Redistributable is present for the prebuilt llama.cpp and PyTorch The prebuilt llama-server.exe and the PyTorch wheels dynamically link the MSVC runtime (VCRUNTIME140.dll, MSVCP140.dll, VCRUNTIME140_1.dll). The Universal CRT ships with Windows 10+, but the VC++ 2015-2022 redistributable does not, so a clean box can fail to launch llama-server or import torch with a missing VCRUNTIME140.dll. - Add Test-VCRedistInstalled (System32 vcruntime140_1.dll, with a registry fallback gated on version 14.20+) and Ensure-VCRedist (winget Microsoft.VCRedist.2015+.x64, non-fatal), called as Phase 1b.5 so it runs even on the no-build-tools prebuilt path. It is a no-op when the runtime is already present, which is the common case. - Add Pester tests for the detection: present via the DLL, present via the registry, absent, and an old 2015-only redist that is too low. * Add a CI job that validates the VC++ runtime detection on a real Windows runner Runs on windows-latest and windows-2025-vs2026: asserts Test-VCRedistInstalled reports present on the stock image, removes both detection signals (the System32 DLL via a redirected SystemRoot and the HKLM runtime keys, restorably) to confirm detection fires on a genuinely clean box, then does a literal uninstall/reinstall round trip with the official installer and the Ensure-VCRedist winget path. The runtime is restored before the job ends. * Dot-source the full logging closure in the VC++ runtime CI job Ensure-VCRedist calls step/substep, which reach Write-StudioStdoutMirror and Get-StudioAnsi; extract those too so the job does not fail with an unrecognized command. Also note that the runtime is ref-counted by Visual Studio on the hosted image, so the literal package uninstall is a no-op there (the clean-box section already proves detection fires when the runtime is genuinely absent). * Tighten comments in setup.ps1, the VS2026 tests and workflow Comment-only: condense the verbose helper/test/CI comments to one or two lines, drop the obvious ones, keep the non-obvious rationale. Verified comment-only by comparing the PowerShell code-token stream before and after (no code tokens changed); Pester suite still green. * Fold the no-VS and setup.ps1 VS2026 Windows CI into studio-windows-inference-smoke.yml Move the no-vs-cpu/no-vs-gpu-resolve and pester/vs-integration/vcredist-clean-box jobs into the existing Windows GGUF CI workflow and delete the two standalone files, so a studio change triggers one Windows workflow instead of three. Path filter gains tests/studio_setup_ps1/**; job keys and artifact names stay unique. * CI: assert a Windows ROCm prebuilt exists in the no-VS resolve job The no-vs-gpu-resolve job confirmed a Windows CUDA asset but never a ROCm one, and the resolver step resolves to CPU on hosted runners (no AMD GPU), so the AMD no-VS guarantee rode only on shared resolver code. Grep the per-gfx windows-x64-rocm-gfx bundles in the same asset-availability step so a release that drops the Windows ROCm prebuilts fails loudly. --------- Co-authored-by: Daniel Han --- .../studio-windows-inference-smoke.yml | 535 ++++++++++++++++++ studio/setup.ps1 | 451 ++++++++++++--- tests/studio_setup_ps1/Get-FunctionSource.ps1 | 55 ++ .../Studio.Setup.Vs2026.Tests.ps1 | 368 ++++++++++++ 4 files changed, 1316 insertions(+), 93 deletions(-) create mode 100644 tests/studio_setup_ps1/Get-FunctionSource.ps1 create mode 100644 tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index ceae8e049d..15a1affbc5 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -26,6 +26,7 @@ on: - 'unsloth_cli/**' - 'install.ps1' - 'pyproject.toml' + - 'tests/studio_setup_ps1/**' - '.github/workflows/studio-windows-inference-smoke.yml' push: branches: [main, pip] @@ -1244,3 +1245,537 @@ jobs: logs/install.log logs/llama-server/*.log retention-days: 7 + + # ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ── + no-vs-cpu: + name: Studio install + inference without Visual Studio + runs-on: windows-latest + timeout-minutes: 35 + defaults: + run: + shell: bash + 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: '18820' + HF_HOME: ${{ github.workspace }}/hf-cache + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - 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' + + - 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" + bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf + + - 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: Pre-install Windows tweaks (npm 11 + Defender exclusions) + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + npm install -g 'npm@^11' 2>&1 | Out-Host + foreach ($p in @( + "$env:USERPROFILE\.unsloth", + "$env:USERPROFILE\AppData\Local\uv", + "$env:GITHUB_WORKSPACE\studio\frontend\node_modules", + "$env:GITHUB_WORKSPACE\studio\frontend\dist" + )) { + try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { } + } + + - name: Hide Visual Studio + CMake (simulate a host with no build tools) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Rename the Visual Studio install roots (incl. the Installer that holds + # vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss. + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + if (Test-Path -LiteralPath $d) { + Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff') + Write-Host "Hid VS: $d" + } + } + # Surgically rename each cmake executable on PATH (not its parent dir -- + # cmake can share a dir with other shims) so Get-Command cmake fails. + $hidden = @() + foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) { + if ($c.Source -and (Test-Path -LiteralPath $c.Source)) { + Rename-Item -LiteralPath $c.Source -NewName ((Split-Path $c.Source -Leaf) + '.off') + $hidden += $c.Source + Write-Host "Hid cmake: $($c.Source)" + } + } + ("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Assert Visual Studio + CMake are genuinely undetectable + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) { + . ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn))) + } + $vs = Find-VsBuildTools + if ($vs) { Write-Error "Find-VsBuildTools still detects VS: $($vs.Generator) @ $($vs.InstallPath)"; exit 1 } + if (Get-Command cmake -ErrorAction SilentlyContinue) { Write-Error "cmake is still on PATH"; exit 1 } + if (Get-Command cl.exe -ErrorAction SilentlyContinue) { Write-Error "cl.exe is still on PATH"; exit 1 } + Write-Host "Confirmed: no Visual Studio, no cmake, no cl.exe." + + - name: PyTorch CPU wheel installs and imports (no Visual Studio) + run: | + python -m pip install --upgrade pip + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())" + + - name: Install Studio (--local, --no-torch) with no build tools present + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + New-Item -ItemType Directory -Force -Path logs | Out-Null + $ProgressPreference = 'SilentlyContinue' + & ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log + + - name: Assert prebuilt used AND no build tools were installed + run: | + LLAMA_DIR=~/.unsloth/llama.cpp + INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json" + BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe" + fail=0 + if grep -q "falling back to source build" logs/install.log; then + echo "::error::install.ps1 fell back to source-build llama.cpp without VS."; fail=1 + fi + # The deferred build-tool installs must NOT run on the prebuilt path. + for pat in "Kitware.CMake" "Microsoft.VisualStudio.2022.BuildTools" "installing via winget"; do + if grep -qi "$pat" logs/install.log; then + echo "::error::unexpected build-tool install on the prebuilt path: '$pat'"; fail=1 + fi + done + [ -f "$INFO" ] || { echo "::error::no UNSLOTH_PREBUILT_INFO.json"; ls -la "$LLAMA_DIR" || true; fail=1; } + [ -f "$BIN" ] || { echo "::error::no llama-server.exe"; ls -la "$LLAMA_DIR/build/bin" || true; fail=1; } + if [ "$fail" != "0" ]; then grep -iE "cmake|visual studio|prebuilt|source build" logs/install.log | tail -60; exit 1; fi + echo "Prebuilt installed with no build tools:" + cat "$INFO" + + - name: Add Studio shim to GITHUB_PATH + run: | + SHIM_DIR=~/.unsloth/studio/bin + [ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; } + cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" + + - name: Reset auth + boot Studio (API-only) + run: | + unsloth studio reset-password + mkdir -p logs + UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ + > logs/studio.log 2>&1 & + echo "STUDIO_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for /api/health, log in, load the GGUF + run: | + for i in $(seq 1 180); do + if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then + jq -e '.status == "healthy"' /tmp/health.json && break + fi + sleep 1 + done + jq -e '.status == "healthy"' /tmp/health.json || { tail -200 logs/studio.log; exit 1; } + OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password) + NEW="CINoVS-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')" + echo "::add-mask::$OLD" + echo "::add-mask::$NEW" + OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token) + curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \ + -H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \ + -d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null + TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token) + echo "API_KEY=$TOKEN" >> "$GITHUB_ENV" + LOAD_OK=0 + for attempt in 1 2 3; do + HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \ + -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + --max-time 600 \ + -d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}") + if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi + echo "::warning::/api/inference/load attempt $attempt returned $HTTP"; cat /tmp/load.json || true; sleep 10 + done + [ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; } + jq '{status, display_name, is_gguf}' /tmp/load.json + + - name: Inference works via the prebuilt llama.cpp (no VS) + run: | + RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \ + --max-time 240 \ + -d '{"model":"default","messages":[{"role":"user","content":"What is 1+1? Answer briefly."}],"temperature":0,"max_tokens":32,"stream":false}') + echo "$RESP" | jq '.choices[0].message' || { echo "$RESP"; exit 1; } + CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content') + [ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; } + echo "Inference OK without Visual Studio: $CONTENT" + + - name: Restore Visual Studio + CMake + if: always() + shell: pwsh + run: | + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + $off = "$d.vsoff" + if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" } + } + if ($env:HIDDEN_CMAKE) { + foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) { + if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) } + } + } + + - name: Stop Studio + if: always() + shell: cmd + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + + - name: Collect llama-server logs + if: always() + continue-on-error: true + run: | + mkdir -p logs/llama-server + cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || echo "no llama-server logs" + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-no-vs-cpu-log + path: | + logs/install.log + logs/studio.log + logs/llama-server/*.log + retention-days: 7 + + # ───────────────────────────────────────────────────────────────────── + # Job B: the GPU (CUDA) prebuilt path is also VS-free (resolve/availability) + # ───────────────────────────────────────────────────────────────────── + no-vs-gpu-resolve: + name: GPU prebuilt resolves without Visual Studio + runs-on: windows-latest + timeout-minutes: 15 + defaults: + run: + shell: bash + env: + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Hide Visual Studio + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + if (Test-Path -LiteralPath $d) { Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" } + } + + - name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/unslothai/llama.cpp/releases/latest" > /tmp/rel.json + echo "release: $(jq -r .tag_name /tmp/rel.json)" + ASSETS=$(jq -r '.assets[].name' /tmp/rel.json) + echo "$ASSETS" | grep -iE 'windows-x64-cuda[0-9]' || { + echo "::error::no Windows x64 CUDA prebuilt asset found in unslothai/llama.cpp latest release" + echo "$ASSETS"; exit 1; } + # AMD parity: hosted runners have no AMD GPU, so the resolver step below + # can't exercise the ROCm path (it resolves to CPU). Pin the per-gfx + # Windows ROCm bundles here so a release that drops them fails loudly -- + # the AMD no-VS guarantee otherwise rides only on shared resolver code. + echo "$ASSETS" | grep -iE 'windows-x64-rocm-gfx' || { + echo "::error::no Windows x64 ROCm (per-gfx) prebuilt asset found in unslothai/llama.cpp latest release" + echo "$ASSETS"; exit 1; } + echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling." + + - name: The prebuilt resolver runs without Visual Studio + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Resolver-only (no GPU on hosted runners, so the host resolves to the + # CPU bundle). The point is that resolution needs no compiler/VS. + python -m pip install --upgrade huggingface_hub + python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || { + echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; } + cat /tmp/resolve.json + echo "Prebuilt resolver ran with no Visual Studio present." + + - name: Restore Visual Studio + if: always() + shell: pwsh + run: | + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + $off = "$d.vsoff" + if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" } + } + + # ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ── + pester: + name: setup.ps1 unit tests (VS 2026 / CMake guard) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Pester v5 + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser + Import-Module Pester -MinimumVersion 5.5.0 + Get-Module Pester | Select-Object Name, Version | Format-Table + + - name: Run Pester suite + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $testDir = Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1' + if (-not (Test-Path $testDir)) { + Write-Error "Test directory not found: $testDir" + exit 1 + } + $cfg = New-PesterConfiguration + $cfg.Run.Path = $testDir + $cfg.Run.Exit = $true # non-zero exit => job fails + $cfg.Run.Throw = $true # also throw on test failure / 0 tests + $cfg.TestResult.Enabled = $true + $cfg.TestResult.OutputFormat = 'NUnitXml' + $cfg.TestResult.OutputPath = Join-Path $env:GITHUB_WORKSPACE 'pester-results.xml' + $cfg.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $cfg + + - name: Upload Pester results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pester-results-setup-ps1 + path: pester-results.xml + if-no-files-found: warn + + vs-integration: + # Real detection against the VS installed on the runner image (no mocks). + name: real-VS detection (${{ matrix.label }}) + strategy: + fail-fast: false + matrix: + include: + - { os: windows-2022, label: 'VS 2022', expectGen: 'Visual Studio 17 2022', expectToolset: 'v170' } + - { os: windows-2025-vs2026, label: 'VS 2026', expectGen: 'Visual Studio 18 2026', expectToolset: 'v180' } + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect the real Visual Studio with setup.ps1 functions + shell: pwsh + env: + EXPECT_GEN: ${{ matrix.expectGen }} + EXPECT_TOOLSET: ${{ matrix.expectToolset }} + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Get-VcBuildCustomizationsDir', 'Find-VsBuildTools')) { + . ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn))) + } + + # Ground truth from the real vswhere (independent of our code), for visibility. + $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsw) { + $year = (& $vsw -latest -property catalog_productLineVersion 2>$null | Select-Object -First 1) + $path = (& $vsw -latest -property installationPath 2>$null | Select-Object -First 1) + Write-Host "Real vswhere: productLineVersion='$year' installPath='$path'" + } else { + Write-Host "vswhere not present at $vsw (relying on filesystem fallback)" + } + + # Our detection must find the real VS and report the expected generator. + $r = Find-VsBuildTools + if (-not $r) { throw "Find-VsBuildTools returned null on a host with real $env:EXPECT_GEN" } + Write-Host "Find-VsBuildTools -> Generator='$($r.Generator)' Source='$($r.Source)' InstallPath='$($r.InstallPath)'" + if ($r.Generator -ne $env:EXPECT_GEN) { + throw "Detection mismatch: got '$($r.Generator)', expected '$env:EXPECT_GEN'" + } + if (-not (Test-Path $r.InstallPath)) { throw "Detected InstallPath does not exist: $($r.InstallPath)" } + + # Toolset path derivation must match the expected v-number... + $bc = Get-VcBuildCustomizationsDir -VsInstallPath $r.InstallPath -Generator $r.Generator + $derived = Split-Path (Split-Path $bc -Parent) -Leaf # e.g. v170 / v180 + Write-Host "Get-VcBuildCustomizationsDir -> '$bc' (toolset='$derived')" + if ($derived -ne $env:EXPECT_TOOLSET) { + throw "Toolset mismatch: derived '$derived', expected '$env:EXPECT_TOOLSET'" + } + + # ...and that v-number is a real folder on the VS install (where CUDA's + # BuildCustomizations would land). + $vcRoot = Join-Path $r.InstallPath 'MSBuild\Microsoft\VC' + if (Test-Path $vcRoot) { + $realToolsets = @((Get-ChildItem -Path $vcRoot -Directory -ErrorAction SilentlyContinue).Name) + Write-Host "Real VC toolset dirs: $($realToolsets -join ', ')" + if ($realToolsets -notcontains $derived) { + throw "Derived toolset '$derived' is not present on the real $env:EXPECT_GEN install (have: $($realToolsets -join ', '))" + } + Write-Host "OK: toolset '$derived' exists on the real VS install." + } else { + Write-Warning "VC MSBuild root absent ($vcRoot) - C++ workload not installed; skipping on-disk toolset check." + } + + Write-Host "PASS: real $env:EXPECT_GEN detected correctly with toolset '$derived'." + + vcredist-clean-box: + # Validate Test-VCRedistInstalled + Ensure-VCRedist on a throwaway runner: + # present on the stock image, fires on a clean box (signals removed restorably), + # then a literal uninstall/reinstall round trip. Always restored before the end. + name: VC++ runtime detect + install round-trip (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, windows-2025-vs2026] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect present, fire on a clean box, and round-trip the install + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + # Dot-source the guard + the logging closure it reaches + # (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi). + $script:StudioVtOk = $false + $script:UnslothVerbose = $false + foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep', + 'Invoke-SetupCommand', 'Refresh-Environment', + 'Test-VCRedistInstalled', 'Ensure-VCRedist')) { + $src = Get-FunctionSource -Path $setup -Name $fn + if (-not $src) { throw "Function '$fn' not found in setup.ps1" } + . ([scriptblock]::Create($src)) + } + + $regKeys = @( + 'HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', + 'HKLM\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64' + ) + function Show-GroundTruth { + $dll = Join-Path $env:SystemRoot 'System32\vcruntime140_1.dll' + Write-Host (" System32\vcruntime140_1.dll present: {0}" -f (Test-Path $dll)) + foreach ($k in $regKeys) { + $r = Get-ItemProperty -Path "HKLM:\$($k.Substring(5))" -ErrorAction SilentlyContinue + if ($r) { Write-Host (" {0}: Installed={1} {2}.{3}" -f $k, $r.Installed, $r.Major, $r.Minor) } + else { Write-Host (" {0}: (absent)" -f $k) } + } + } + + Write-Host '== A. Detection on the stock runner (expect present) ==' + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'Test-VCRedistInstalled reported ABSENT on a stock runner that ships the VC++ runtime (detection regression).' } + Write-Host ' Test-VCRedistInstalled -> present OK' + + Write-Host '== B. Genuinely clean box (restorable): detection must FIRE ==' + $scratch = Join-Path $env:RUNNER_TEMP 'cleanwin' + New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'System32') | Out-Null + $backup = Join-Path $env:RUNNER_TEMP 'vcreg_backup' + New-Item -ItemType Directory -Force -Path $backup | Out-Null + $origSysRoot = $env:SystemRoot + try { + for ($i = 0; $i -lt $regKeys.Count; $i++) { + reg query $regKeys[$i] *> $null + if ($LASTEXITCODE -eq 0) { + reg export $regKeys[$i] (Join-Path $backup "$i.reg") /y *> $null + reg delete $regKeys[$i] /f *> $null + } + } + $env:SystemRoot = $scratch + if (Test-VCRedistInstalled) { throw 'Detection still PRESENT after both signals were removed (it would never trigger an install on a clean box).' } + Write-Host ' Test-VCRedistInstalled -> absent OK (detection fires on a clean box)' + } finally { + $env:SystemRoot = $origSysRoot + for ($i = 0; $i -lt $regKeys.Count; $i++) { + $f = Join-Path $backup "$i.reg" + if (Test-Path $f) { reg import $f *> $null } + } + } + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'Detection did not recover after restoring the registry (test restore bug).' } + + Write-Host '== C. Literal uninstall on this throwaway VM (official installer), observe detection ==' + $exe = Join-Path $env:RUNNER_TEMP 'vc_redist.x64.exe' + Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $exe + Start-Process -FilePath $exe -ArgumentList '/uninstall', '/quiet', '/norestart' -Wait + Show-GroundTruth + Write-Host (" Test-VCRedistInstalled after uninstall -> {0}" -f (Test-VCRedistInstalled)) + if (Test-VCRedistInstalled) { + Write-Host ' Note: the Visual Studio on this image ref-counts the runtime, so the package' + Write-Host ' uninstall is a no-op here; section B already proved detection on a clean box.' + } + + Write-Host '== D. Restore via Ensure-VCRedist (winget product path), installer fallback if needed ==' + Ensure-VCRedist + if (-not (Test-VCRedistInstalled)) { + Write-Host ' winget path did not restore it; using the official installer to close the round trip.' + Start-Process -FilePath $exe -ArgumentList '/install', '/quiet', '/norestart' -Wait + } + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'VC++ runtime could not be restored after the uninstall round-trip.' } + Write-Host ' Test-VCRedistInstalled -> present OK' + Write-Host 'PASS: detection is correct on a real install, fires on a clean box, and the install round-trip restores the runtime.' diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 28941d91a6..20d53734a1 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -390,45 +390,199 @@ function Get-PytorchCudaTag { return "cu126" } -# Find Visual Studio Build Tools for cmake -G flag. -# Strategy: (1) vswhere, (2) scan filesystem (handles broken vswhere registration). -# Returns @{ Generator = "Visual Studio 17 2022"; InstallPath = "C:\..."; Source = "..." } or $null. -function Find-VsBuildTools { - $map = @{ '2022' = '17'; '2019' = '16'; '2017' = '15' } +# VS generator -> MSBuild BuildCustomizations dir; toolset tracks the VS major +# (18->v180, 17->v170), defaulting to v170 when unparseable. +function Get-VcBuildCustomizationsDir { + param( + [Parameter(Mandatory)][string]$VsInstallPath, + [string]$Generator + ) + $toolset = 'v170' + if ($Generator -and ($Generator -match 'Visual Studio (\d+)\b')) { + $toolset = "v$($Matches[1])0" + } + return (Join-Path $VsInstallPath "MSBuild\Microsoft\VC\$toolset\BuildCustomizations") +} - # --- Try vswhere first (works when VS is properly registered) --- +# Installed cmake version, or $null if absent/unparseable. +function Get-CmakeVersion { + $raw = & cmake --version 2>$null | Select-Object -First 1 + if ($raw -and ($raw -match '(\d+)\.(\d+)(?:\.(\d+))?')) { + $patch = if ($Matches[3]) { $Matches[3] } else { '0' } + return [version]"$($Matches[1]).$($Matches[2]).$patch" + } + return $null +} + +# VS 18 2026 generator needs cmake >= 4.2 (added there); true for older VS generators. +function Test-CmakeSupportsGenerator { + param( + [Parameter(Mandatory)][string]$CmakeVersion, + [Parameter(Mandatory)][string]$Generator + ) + if ($Generator -match 'Visual Studio 18\b') { + $clean = ($CmakeVersion -replace '[^0-9.].*$', '').TrimEnd('.') + try { $v = [version]$clean } catch { return $false } + return ($v -ge [version]'4.2') + } + return $true +} + +function Test-CmakeListsGenerator { + # Does `cmake --help` actually list the generator? A VS-bundled cmake can drive + # VS 2026 below the 4.2 floor, so probe rather than trust the version. (#6473) + param([Parameter(Mandatory)][string]$Generator) + $help = & cmake --help 2>$null | Out-String + if (-not $help) { return $false } + $haystack = ($help -replace '\s+', ' ') + $needle = ($Generator -replace '\s+', ' ') + return $haystack.Contains($needle) +} + +function Test-CmakeCanDriveGenerator { + # cmake can drive $Generator if it lists it (VS-bundled below 4.2) or meets the floor. + param([Parameter(Mandatory)][string]$Generator) + if (Test-CmakeListsGenerator -Generator $Generator) { return $true } + $verObj = Get-CmakeVersion + $verStr = if ($verObj) { $verObj.ToString() } else { '0.0' } + return (Test-CmakeSupportsGenerator -CmakeVersion $verStr -Generator $Generator) +} + +function Add-DefaultCmakeToPath { + # Prepend the default CMake dir so a freshly winget-installed cmake wins over an + # older one already on PATH. $true if found. (#6473) + $cmakeDefaults = @( + "$env:ProgramFiles\CMake\bin", + "${env:ProgramFiles(x86)}\CMake\bin", + "$env:LOCALAPPDATA\CMake\bin" + ) + foreach ($d in $cmakeDefaults) { + if (Test-Path (Join-Path $d "cmake.exe")) { + $env:Path = "$d;$env:Path" + Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null + return $true + } + } + return $false +} + +function Get-FallbackVsGenerator { + # Newest pre-2026 VS whose generator the current cmake can drive, for when the + # VS 2026 generator is unusable (old/offline cmake) but an older toolchain exists. + # vswhere first (catches non-default roots like D:\), then Program Files; matches + # Find-VsBuildTools. Returns @{ Generator; InstallPath } or $null. (#6473) + $knownEditions = @('BuildTools', 'Community', 'Professional', 'Enterprise', 'Preview') + + # install path if it holds a usable cl.exe, else $null + $tryCandidate = { + param($gen, $installPath) + if (-not $installPath) { return $null } + $vcDir = Join-Path $installPath "VC\Tools\MSVC" + if (-not (Test-Path $vcDir)) { return $null } + $cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($cl) { return @{ Generator = $gen; InstallPath = $installPath } } + return $null + } + + # vswhere (non-default roots) + $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsw) { + $json = & $vsw -all -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -format json 2>$null | Out-String + if ($json) { + try { $instances = @($json | ConvertFrom-Json) } catch { $instances = @() } + $ranked = $instances | ForEach-Object { + $label = if ($_.catalog -and $_.catalog.productLineVersion) { [string]$_.catalog.productLineVersion } else { '' } + [pscustomobject]@{ Gen = (Resolve-VsGeneratorFromLabel $label); Path = [string]$_.installationPath } + } | Where-Object { $_.Gen -and ($_.Gen -notmatch 'Visual Studio 18\b') } + # newest first: 2022 > 2019 > 2017 + $ranked = $ranked | Sort-Object { switch -regex ($_.Gen) { '17 2022' {0} '16 2019' {1} '15 2017' {2} default {9} } } + foreach ($cand in $ranked) { + if (-not (Test-CmakeListsGenerator -Generator $cand.Gen)) { continue } + $res = & $tryCandidate $cand.Gen $cand.Path + if ($res) { return $res } + } + } + } + + # Program Files scan + $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } + $older = @( + @{ Dir = '2022'; Generator = 'Visual Studio 17 2022' }, + @{ Dir = '2019'; Generator = 'Visual Studio 16 2019' }, + @{ Dir = '2017'; Generator = 'Visual Studio 15 2017' } + ) + foreach ($entry in $older) { + if (-not (Test-CmakeListsGenerator -Generator $entry.Generator)) { continue } + foreach ($r in $roots) { + $vsBase = Join-Path $r "Microsoft Visual Studio\$($entry.Dir)" + if (-not (Test-Path $vsBase)) { continue } + foreach ($ed in $knownEditions) { + $candidate = Join-Path $vsBase $ed + if (-not (Test-Path $candidate)) { continue } + $res = & $tryCandidate $entry.Generator $candidate + if ($res) { return $res } + } + } + } + return $null +} + +# VS version label -> cmake generator. vswhere's productLineVersion is the year for +# VS <= 2022 but the internal major "18" for VS 2026, and dir names use either form, +# so accept both. (VS 2026 detection adapted from @LeoBorcherding's #6038.) +function Resolve-VsGeneratorFromLabel { + param([string]$Label) + if (-not $Label) { return $null } + $map = @{ + '2026' = 'Visual Studio 18 2026'; '18' = 'Visual Studio 18 2026' + '2022' = 'Visual Studio 17 2022'; '17' = 'Visual Studio 17 2022' + '2019' = 'Visual Studio 16 2019'; '16' = 'Visual Studio 16 2019' + '2017' = 'Visual Studio 15 2017'; '15' = 'Visual Studio 15 2017' + } + return $map[$Label.Trim()] +} + +# Find VS Build Tools for cmake -G: vswhere, then a filesystem scan (handles broken +# vswhere registration). Returns @{ Generator; InstallPath; Source } or $null. +function Find-VsBuildTools { + # vswhere first (works when VS is properly registered) $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" if (Test-Path $vsw) { $info = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property catalog_productLineVersion 2>$null $path = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null if ($info -and $path) { - $y = $info.Trim() - $n = $map[$y] - if ($n) { - return @{ Generator = "Visual Studio $n $y"; InstallPath = $path.Trim(); Source = 'vswhere' } + $gen = Resolve-VsGeneratorFromLabel $info + if ($gen) { + return @{ Generator = $gen; InstallPath = $path.Trim(); Source = 'vswhere' } } } } - # --- Scan filesystem (handles broken vswhere registration after winget cycles) --- - $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) - $editions = @('BuildTools', 'Community', 'Professional', 'Enterprise') - $years = @('2022', '2019', '2017') + # filesystem scan (handles broken vswhere registration); VS 2026+ dir is "18" + $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } + $knownEditions = @('BuildTools', 'Community', 'Professional', 'Enterprise', 'Preview') + $dirs = @('18', '2026', '2022', '2019', '2017') - foreach ($y in $years) { + foreach ($d in $dirs) { + $gen = Resolve-VsGeneratorFromLabel $d + if (-not $gen) { continue } foreach ($r in $roots) { - foreach ($ed in $editions) { - $candidate = Join-Path $r "Microsoft Visual Studio\$y\$ed" - if (Test-Path $candidate) { - $vcDir = Join-Path $candidate "VC\Tools\MSVC" - if (Test-Path $vcDir) { - $cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($cl) { - $n = $map[$y] - if ($n) { - return @{ Generator = "Visual Studio $n $y"; InstallPath = $candidate; Source = "filesystem ($ed)"; ClExe = $cl.FullName } - } - } + $vsBase = Join-Path $r "Microsoft Visual Studio\$d" + if (-not (Test-Path $vsBase)) { continue } + # VS 2026 (dir "18") may use non-standard edition names, so scan every subdir + if ($d -eq '18' -or $d -eq '2026') { + $editionCandidates = Get-ChildItem -Path $vsBase -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName } + } else { + $editionCandidates = $knownEditions | ForEach-Object { Join-Path $vsBase $_ } + } + foreach ($candidate in $editionCandidates) { + if (-not (Test-Path $candidate)) { continue } + $vcDir = Join-Path $candidate "VC\Tools\MSVC" + if (Test-Path $vcDir) { + $cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($cl) { + $ed = Split-Path $candidate -Leaf + return @{ Generator = $gen; InstallPath = $candidate; Source = "filesystem ($ed)"; ClExe = $cl.FullName } } } } @@ -438,6 +592,103 @@ function Find-VsBuildTools { return $null } +# Install CMake + VS Build Tools, deferred here from Phase 1 so the prebuilt path +# never pays for a multi-GB install. Called only when a source build is committed. +# CMake is best-effort (build skips downstream if absent); VS Build Tools are +# required, so exit 1 with guidance if missing. No-ops for VS when already detected. +function Ensure-BuildToolsForLlamaSourceBuild { + # CMake + if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) { + Write-Host "CMake not found -- installing via winget (needed for the llama.cpp source build)..." -ForegroundColor Yellow + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + try { + Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { } + } + # winget may install cmake but not put it on PATH yet; try the default dir + if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) { + $cmakeDefaults = @( + "$env:ProgramFiles\CMake\bin", + "${env:ProgramFiles(x86)}\CMake\bin", + "$env:LOCALAPPDATA\CMake\bin" + ) + foreach ($d in $cmakeDefaults) { + if (Test-Path (Join-Path $d "cmake.exe")) { + $env:Path = "$d;$env:Path" + Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null + break + } + } + } + if ($null -ne (Get-Command cmake -ErrorAction SilentlyContinue)) { step "cmake" "installed" } + } + + # VS Build Tools + if ($script:VsInstallPath) { return } # already detected by the early probe + $vsResult = Find-VsBuildTools + if (-not $vsResult) { + Write-Host "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow + Write-Host " (Needed only for the llama.cpp source build; may take several minutes)" -ForegroundColor Gray + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + $prevEAPTemp = $ErrorActionPreference + $ErrorActionPreference = "Continue" + winget install Microsoft.VisualStudio.2022.BuildTools --source winget --accept-package-agreements --accept-source-agreements --override "--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --passive --wait" + $ErrorActionPreference = $prevEAPTemp + # Re-scan after install (don't trust vswhere catalog) + $vsResult = Find-VsBuildTools + } + } + if ($vsResult) { + $script:CmakeGenerator = $vsResult.Generator + $script:VsInstallPath = $vsResult.InstallPath + step "vs" "$($vsResult.Generator) ($($vsResult.Source))" + if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" } + } else { + Write-Host "[ERROR] Visual Studio Build Tools are required for the llama.cpp source build but could not be found or installed." -ForegroundColor Red + Write-Host " Manual install:" -ForegroundColor Red + Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow + Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow + exit 1 + } +} + +# Detect the VC++ 2015-2022 Redistributable that the prebuilt llama-server and +# PyTorch need (they link VCRUNTIME140_1.dll etc., which the Universal CRT lacks). +# Signal is System32\vcruntime140_1.dll (VS 2019+), registry as fallback. +function Test-VCRedistInstalled { + $sys = $env:SystemRoot + if ($sys -and (Test-Path (Join-Path $sys 'System32\vcruntime140_1.dll'))) { return $true } + foreach ($k in @( + 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64' + )) { + try { + $r = Get-ItemProperty -Path $k -ErrorAction Stop + if ($r.Installed -eq 1 -and [int]$r.Major -ge 14 -and [int]$r.Minor -ge 20) { return $true } + } catch { } + } + return $false +} + +# Install the VC++ 2015-2022 runtime if missing (non-fatal; usually a no-op). +function Ensure-VCRedist { + if (Test-VCRedistInstalled) { step "vcredist" "present"; return } + Write-Host "Microsoft Visual C++ Redistributable (2015-2022) is missing; the prebuilt llama.cpp and PyTorch need it. Installing the runtime..." -ForegroundColor Yellow + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + try { + Invoke-SetupCommand { winget install --id Microsoft.VCRedist.2015+.x64 --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { substep "VCRedist install failed: $($_.Exception.Message)" "Yellow" } + } + if (Test-VCRedistInstalled) { step "vcredist" "installed" } + else { + substep "Could not install the VC++ Redistributable automatically." "Yellow" + substep "If llama-server or torch reports a missing VCRUNTIME140.dll, install:" "Yellow" + substep "https://aka.ms/vs/17/release/vc_redist.x64.exe" "Yellow" + } +} + # ───────────────────────────────────────────── # Output style (aligned with studio/setup.sh: step / substep) # ───────────────────────────────────────────── @@ -1110,83 +1361,39 @@ if (-not $HasGit) { } # ============================================ -# 1c. CMake (required for llama.cpp build) +# 1b.5. Visual C++ Redistributable (runtime for the prebuilt llama.cpp + PyTorch) # ============================================ +# Runtime dep, not a build tool: the prebuilt llama-server and PyTorch load it. +Ensure-VCRedist + +# ============================================ +# 1c. CMake (only needed for a llama.cpp SOURCE build -- detection only) +# ============================================ +# Detection only: the prebuilt path needs no compiler, so do not install or exit +# here. Ensure-BuildToolsForLlamaSourceBuild installs CMake if a source build runs. $HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) -if (-not $HasCmake) { - Write-Host "CMake not found -- installing via winget..." -ForegroundColor Yellow - $HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) - if ($HasWinget) { - try { - Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null - Refresh-Environment - $HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) - } catch { } - } - # winget may succeed but cmake isn't on PATH yet (MSI PATH changes need a - # new shell). Try the default install location as a fallback. - if (-not $HasCmake) { - $cmakeDefaults = @( - "$env:ProgramFiles\CMake\bin", - "${env:ProgramFiles(x86)}\CMake\bin", - "$env:LOCALAPPDATA\CMake\bin" - ) - foreach ($d in $cmakeDefaults) { - if (Test-Path (Join-Path $d "cmake.exe")) { - $env:Path = "$d;$env:Path" - # Persist to user PATH (Prepend so this cmake wins over older ones). - Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null - $HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) - if ($HasCmake) { - Write-Host " Found cmake at $d (added to PATH)" -ForegroundColor Gray - break - } - } - } - } - if ($HasCmake) { - step "cmake" "installed" - } else { - Write-Host "[ERROR] CMake is required but could not be installed." -ForegroundColor Red - Write-Host " Install CMake from https://cmake.org/download/ and re-run." -ForegroundColor Red - exit 1 - } -} else { +if ($HasCmake) { step "cmake" "$(cmake --version | Select-Object -First 1)" +} else { + step "cmake" "not detected (only needed if a llama.cpp source build is required)" "Yellow" } # ============================================ -# 1d. Visual Studio Build Tools (C++ compiler for llama.cpp) +# 1d. Visual Studio Build Tools (only needed for a llama.cpp SOURCE build -- detection only) # ============================================ +# Detection only: detect VS for a possible source build, but never install or exit +# here. Install is deferred to Ensure-BuildToolsForLlamaSourceBuild. $CmakeGenerator = $null $VsInstallPath = $null $vsResult = Find-VsBuildTools -if (-not $vsResult) { - Write-Host "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow - Write-Host " (This is a one-time install, may take several minutes)" -ForegroundColor Gray - $HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) - if ($HasWinget) { - $prevEAPTemp = $ErrorActionPreference - $ErrorActionPreference = "Continue" - winget install Microsoft.VisualStudio.2022.BuildTools --source winget --accept-package-agreements --accept-source-agreements --override "--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --passive --wait" - $ErrorActionPreference = $prevEAPTemp - # Re-scan after install (don't trust vswhere catalog) - $vsResult = Find-VsBuildTools - } -} - if ($vsResult) { $CmakeGenerator = $vsResult.Generator $VsInstallPath = $vsResult.InstallPath - step "vs" "$CmakeGenerator ($($vsResult.Source))" + step "vs" "$CmakeGenerator ($($vsResult.Source)) (only used if a source build is needed)" if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" } } else { - Write-Host "[ERROR] Visual Studio Build Tools could not be found or installed." -ForegroundColor Red - Write-Host " Manual install:" -ForegroundColor Red - Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow - Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow - exit 1 + step "vs" "not detected (only needed if a llama.cpp source build is required)" "Yellow" } # ============================================ @@ -1429,7 +1636,7 @@ if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') { # the MSBuild .targets/.props files that let VS compile .cu files are missing. # cmake fails with "No CUDA toolset found". Fix: copy from CUDA extras dir. if ($VsInstallPath -and $CudaToolkitRoot) { - $vsCustomizations = Join-Path $VsInstallPath "MSBuild\Microsoft\VC\v170\BuildCustomizations" + $vsCustomizations = Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator $cudaExtras = Join-Path $CudaToolkitRoot "extras\visual_studio_integration\MSBuildExtensions" if ((Test-Path $cudaExtras) -and (Test-Path $vsCustomizations)) { $hasTargets = Get-ChildItem $vsCustomizations -Filter "CUDA *.targets" -ErrorAction SilentlyContinue @@ -3070,6 +3277,17 @@ if (Test-Path -LiteralPath $LlamaServerBin) { } } +# Install build tools now (last resort) rather than eagerly in Phase 1, so the +# prebuilt path stays fast. Same condition as the if/elseif chain below: a source +# build runs only when needed and no usable binary is already present. +$WillBuildLlamaFromSource = $NeedLlamaSourceBuild -and ` + -not ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") +if ($WillBuildLlamaFromSource) { + Ensure-BuildToolsForLlamaSourceBuild + # refresh so the chain below sees a newly installed cmake + $HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) +} + if (-not $NeedLlamaSourceBuild) { Write-Host "" step "llama.cpp" "prebuilt (validated)" @@ -3092,10 +3310,56 @@ if (-not $NeedLlamaSourceBuild) { substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow" $script:LlamaCppDegraded = $true } else { - # A source build is committed here. The CUDA toolkit is only needed now, so - # resolve (and winget-install if needed) it lazily, failing fast if no - # driver-compatible toolkit exists. The prebuilt path never reaches this. + # Finalize the VS generator (gate/fallback below) BEFORE Resolve-CudaToolkit, + # which copies the CUDA .targets into the current generator's dir; a later swap + # would strand them. The CMake 4.2 gate for VS 2026 is checked only here, in the + # source-build path, so a VS 2026 + cmake < 4.2 host can still use the prebuilt. (#6473) + if ($CmakeGenerator -match 'Visual Studio 18\b') { + if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) { + $cmakeVerObj = Get-CmakeVersion + $cmakeVerStr = if ($cmakeVerObj) { $cmakeVerObj.ToString() } else { '0.0' } + substep "CMake $cmakeVerStr cannot drive the Visual Studio 2026 generator (need 4.2+ or a VS-bundled cmake) -- updating via winget..." "Yellow" + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + # upgrade first (fast if Kitware.CMake is already a winget app), then + # prepend the default dir so the new cmake wins over an older one on PATH + try { + Invoke-SetupCommand { winget upgrade Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { substep "CMake winget upgrade failed: $($_.Exception.Message)" "Yellow" } + Add-DefaultCmakeToPath | Out-Null + # upgrade no-ops if the cmake came from Scoop/Chocolatey/VS, not the + # Kitware winget package; install it so a 4.2+ cmake is available + if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) { + try { + Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { substep "CMake winget install failed: $($_.Exception.Message)" "Yellow" } + Add-DefaultCmakeToPath | Out-Null + } + } + if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) { + # cmake still cannot drive VS 2026; before failing, fall back to an + # older installed VS whose generator it can drive (e.g. VS 2022 + old + # cmake on an offline box keeps building) + $fallback = Get-FallbackVsGenerator + if ($fallback) { + substep "CMake cannot drive $CmakeGenerator; falling back to $($fallback.Generator)" "Yellow" + $CmakeGenerator = $fallback.Generator + $VsInstallPath = $fallback.InstallPath + } else { + Write-Host "[ERROR] CMake 4.2+ is required to build llama.cpp with the Visual Studio 2026 generator, and no older Visual Studio toolchain was found to fall back to." -ForegroundColor Red + Write-Host " Upgrade CMake from https://cmake.org/download/ and re-run, or use a prebuilt llama.cpp bundle." -ForegroundColor Red + exit 1 + } + } + } + substep "CMake can drive the $CmakeGenerator generator" + } + + # CUDA resolved here (fail fast if none), after the final VS generator so its + # .targets land in the toolset cmake actually uses. if ($HasNvidiaSmi) { Resolve-CudaToolkit -RequireOrExit } + Write-Host "" if ($HasNvidiaSmi) { substep "building llama.cpp with CUDA support..." @@ -3447,7 +3711,8 @@ if (-not $NeedLlamaSourceBuild) { Write-Host " Copy contents of:" -ForegroundColor Yellow Write-Host " \extras\visual_studio_integration\MSBuildExtensions" -ForegroundColor Yellow Write-Host " into:" -ForegroundColor Yellow - Write-Host " \MSBuild\Microsoft\VC\v170\BuildCustomizations" -ForegroundColor Yellow + $hintCustomizations = if ($VsInstallPath) { Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator } else { "\MSBuild\Microsoft\VC\v170\BuildCustomizations" } + Write-Host " $hintCustomizations" -ForegroundColor Yellow } } } diff --git a/tests/studio_setup_ps1/Get-FunctionSource.ps1 b/tests/studio_setup_ps1/Get-FunctionSource.ps1 new file mode 100644 index 0000000000..5317cde3a8 --- /dev/null +++ b/tests/studio_setup_ps1/Get-FunctionSource.ps1 @@ -0,0 +1,55 @@ +<# +.SYNOPSIS + Extracts a single `function NAME { ... }` block from a PowerShell script by + brace-matching, WITHOUT executing the script. + +.DESCRIPTION + studio/setup.ps1 is a top-level executing installer (it runs install steps at + load), so it cannot be dot-sourced directly in a test. This helper pulls just + the requested function's source text out of the file so a test can dot-source + ONLY that function. + + Brace matching is naive (it counts '{' / '}' without a full tokenizer). It is + safe for the pure helper functions targeted here because their bodies contain + only balanced braces (e.g. `${env:ProgramFiles(x86)}` is self-balanced) and no + here-strings/comments with stray unbalanced braces. + +.EXAMPLE + $src = Get-FunctionSource -Path studio/setup.ps1 -Name Get-VcBuildCustomizationsDir + . ([scriptblock]::Create($src)) # defines the function in the current scope +#> +function Get-FunctionSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Name + ) + + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $text = Get-Content -Raw -LiteralPath $Path + if ([string]::IsNullOrEmpty($text)) { return $null } + + # Match "function " at the start of a line (multiline, case-insensitive). + $pattern = "(?im)^\s*function\s+$([regex]::Escape($Name))\b" + $m = [regex]::Match($text, $pattern) + if (-not $m.Success) { return $null } + + # Locate the opening brace at/after the match. + $braceStart = $text.IndexOf('{', $m.Index) + if ($braceStart -lt 0) { return $null } + + # Walk braces to the matching close. + $depth = 0 + $end = -1 + for ($i = $braceStart; $i -lt $text.Length; $i++) { + $c = $text[$i] + if ($c -eq '{') { $depth++ } + elseif ($c -eq '}') { + $depth-- + if ($depth -eq 0) { $end = $i; break } + } + } + if ($end -lt 0) { return $null } + + return $text.Substring($m.Index, $end - $m.Index + 1) +} diff --git a/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 b/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 new file mode 100644 index 0000000000..35c18770a5 --- /dev/null +++ b/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 @@ -0,0 +1,368 @@ +<# + Pester v5 unit tests for the Visual Studio 2026 completion helpers in + studio/setup.ps1: + - Get-VcBuildCustomizationsDir : derive the VC MSBuild BuildCustomizations + folder (v160 / v170 / v180) from the detected VS generator. + - Test-CmakeSupportsGenerator : gate the "Visual Studio 18 2026" generator + on CMake >= 4.2 (no-op for older VS generators). + + Both are pure functions (no GPU, no Visual Studio, no CUDA, no network), so the + suite runs on a stock windows-latest runner - and on any pwsh host. + + The real functions are extracted from setup.ps1 and dot-sourced (the script is + a top-level installer and cannot be loaded wholesale). Path resolution honors + $env:SETUP_PS1_PATH (set by the PR-validate workflow) and falls back to the + repo-relative path. If a target function cannot be found, the suite FAILS + loudly rather than silently passing. +#> + +BeforeAll { + . (Join-Path $PSScriptRoot 'Get-FunctionSource.ps1') + + $candidates = @( + $env:SETUP_PS1_PATH, + (Join-Path $PSScriptRoot '..\..\studio\setup.ps1') + ) | Where-Object { $_ } + $script:SetupPs1 = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $script:SetupPs1) { throw "Could not locate studio/setup.ps1 (set SETUP_PS1_PATH)." } + Write-Host "setup.ps1 under test: $script:SetupPs1" + + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools', 'Get-VcBuildCustomizationsDir', + 'Test-CmakeSupportsGenerator', 'Get-CmakeVersion', 'Test-CmakeListsGenerator', + 'Test-CmakeCanDriveGenerator', 'Get-FallbackVsGenerator', + 'Ensure-BuildToolsForLlamaSourceBuild', 'Test-VCRedistInstalled')) { + $src = Get-FunctionSource -Path $script:SetupPs1 -Name $fn + if (-not $src) { throw "Function '$fn' not found in $script:SetupPs1 - cannot test the real code." } + . ([scriptblock]::Create($src)) + } +} + +Describe 'Resolve-VsGeneratorFromLabel (vswhere/dir label -> generator)' { + # Guards that detection accepts both '18' (the internal major vswhere reports + # for VS 2026) and the year form. + It 'maps the VS 2026 internal major "18" to the VS 2026 generator' { + Resolve-VsGeneratorFromLabel '18' | Should -Be 'Visual Studio 18 2026' + } + It 'maps the VS 2026 year label "2026" to the VS 2026 generator' { + Resolve-VsGeneratorFromLabel '2026' | Should -Be 'Visual Studio 18 2026' + } + It 'maps the VS 2022 year "2022" and major "17" to the VS 2022 generator' { + Resolve-VsGeneratorFromLabel '2022' | Should -Be 'Visual Studio 17 2022' + Resolve-VsGeneratorFromLabel '17' | Should -Be 'Visual Studio 17 2022' + } + It 'maps 2019/2017 (year and major) to their generators' { + Resolve-VsGeneratorFromLabel '2019' | Should -Be 'Visual Studio 16 2019' + Resolve-VsGeneratorFromLabel '16' | Should -Be 'Visual Studio 16 2019' + Resolve-VsGeneratorFromLabel '2017' | Should -Be 'Visual Studio 15 2017' + Resolve-VsGeneratorFromLabel '15' | Should -Be 'Visual Studio 15 2017' + } + It 'trims whitespace (vswhere output can carry a trailing newline)' { + Resolve-VsGeneratorFromLabel " 18 `n" | Should -Be 'Visual Studio 18 2026' + } + It 'returns null for unknown or empty labels' { + Resolve-VsGeneratorFromLabel '2015' | Should -BeNullOrEmpty + Resolve-VsGeneratorFromLabel '' | Should -BeNullOrEmpty + Resolve-VsGeneratorFromLabel $null | Should -BeNullOrEmpty + } +} + +Describe 'Find-VsBuildTools (VS 2026 generator discovery)' { + # Exercises the real discovery entry point. Windows-only: Find-VsBuildTools builds + # backslash candidate paths that only resolve as directories on Windows. + BeforeAll { + # Define in BeforeAll, not the Describe body: Pester 5 runs the body only at + # discovery, so body-level functions are not visible in the run-phase It blocks. + function New-FakeVsTree { + param([string]$Root, [string]$VersionDir, [string]$Edition = 'BuildTools') + $clDir = Join-Path $Root "Microsoft Visual Studio\$VersionDir\$Edition\VC\Tools\MSVC\14.50.00000\bin\Hostx64\x64" + New-Item -ItemType Directory -Path $clDir -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $clDir 'cl.exe') -Force | Out-Null + } + } + BeforeEach { + $script:OrigPF = ${env:ProgramFiles} + $script:OrigPFx86 = ${env:ProgramFiles(x86)} + } + AfterEach { + ${env:ProgramFiles} = $script:OrigPF + ${env:ProgramFiles(x86)} = $script:OrigPFx86 + } + + It 'detects a filesystem-only VS 2026 BuildTools install (dir "18")' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF' + New-FakeVsTree -Root $root -VersionDir '18' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86' # no vswhere here -> filesystem fallback + $r = Find-VsBuildTools + $r.Generator | Should -Be 'Visual Studio 18 2026' + } + + It 'detects a filesystem-only VS 2026 install under the year dir ("2026")' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF2026' + New-FakeVsTree -Root $root -VersionDir '2026' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86b' + (Find-VsBuildTools).Generator | Should -Be 'Visual Studio 18 2026' + } + + It 'still detects VS 2022 (no regression)' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF2022' + New-FakeVsTree -Root $root -VersionDir '2022' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86c' + (Find-VsBuildTools).Generator | Should -Be 'Visual Studio 17 2022' + } + + It 'detects an older VS installed under the Preview edition dir' -Skip:(-not $IsWindows) { + # Preview installs under a "Preview" edition folder; the fallback must include it. + $root = Join-Path $TestDrive 'PF2022prev' + New-FakeVsTree -Root $root -VersionDir '2022' -Edition 'Preview' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86d' + (Find-VsBuildTools).Generator | Should -Be 'Visual Studio 17 2022' + } +} + +Describe 'Get-VcBuildCustomizationsDir (CUDA to VS MSBuild integration path)' { + # Use TestDrive as the root so Join-Path resolves on any OS; assertions accept + # either path separator. + + It 'derives v180 for the VS 2026 generator' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 18 2026' | + Should -Match 'VC[\\/]v180[\\/]BuildCustomizations$' + } + + It 'derives v170 for VS 2022 (unchanged behavior)' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 17 2022' | + Should -Match 'VC[\\/]v170[\\/]BuildCustomizations$' + } + + It 'derives v160 for VS 2019' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 16 2019' | + Should -Match 'VC[\\/]v160[\\/]BuildCustomizations$' + } + + It 'falls back to v170 when the generator is empty/unparseable (backwards compatible)' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator '' | + Should -Match 'VC[\\/]v170[\\/]BuildCustomizations$' + } + + It 'roots the path under the supplied VS install path' { + $p = Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 18 2026' + $p.StartsWith("$TestDrive") | Should -BeTrue + } +} + +Describe 'Test-CmakeSupportsGenerator (CMake 4.2 guard for VS 2026)' { + + It 'rejects CMake 3.31.0 with the VS 2026 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '3.31.0' -Generator 'Visual Studio 18 2026' | Should -BeFalse + } + + It 'accepts CMake 4.2.1 with the VS 2026 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '4.2.1' -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'accepts CMake exactly 4.2 with the VS 2026 generator (boundary)' { + Test-CmakeSupportsGenerator -CmakeVersion '4.2' -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'rejects CMake 4.1.0 with the VS 2026 generator (boundary)' { + Test-CmakeSupportsGenerator -CmakeVersion '4.1.0' -Generator 'Visual Studio 18 2026' | Should -BeFalse + } + + It 'is a no-op (accepts any CMake) for the VS 2022 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '3.20.0' -Generator 'Visual Studio 17 2022' | Should -BeTrue + } + + It 'is a no-op (accepts any CMake) for the VS 2019 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '3.10.0' -Generator 'Visual Studio 16 2019' | Should -BeTrue + } +} + +Describe 'Test-CmakeListsGenerator (probe cmake --help)' { + # Mock cmake as a function (resolved before any on-PATH exe): PowerShell caches + # its app-path table, so a $env:Path shim would not reliably beat a real cmake. + + It 'returns true when cmake --help lists the generator' { + Mock cmake { "Generators`n Visual Studio 18 2026 = Generates VS 2026 project files.`n Visual Studio 17 2022 = Generates VS 2022 project files." } + Test-CmakeListsGenerator -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'returns false when cmake --help does not list the generator' { + Mock cmake { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + Test-CmakeListsGenerator -Generator 'Visual Studio 18 2026' | Should -BeFalse + } + + It 'returns false when cmake produces no help output' { + Mock cmake { $null } + Test-CmakeListsGenerator -Generator 'Visual Studio 18 2026' | Should -BeFalse + } +} + +Describe 'Test-CmakeCanDriveGenerator (probe OR version floor)' { + It 'accepts a sub-4.2 cmake that lists the VS 2026 generator (bundled cmake)' { + # 3.31.0 is below the 4.2 floor but lists the generator, so the help-probe accepts it. + Mock cmake { + if ($args -contains '--version') { 'cmake version 3.31.0' } + else { "Generators`n Visual Studio 18 2026 = Generates VS 2026 project files." } + } + Test-CmakeCanDriveGenerator -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'accepts a 4.2 cmake via the version floor when the help probe misses it' { + # Help omits the generator but 4.2.0 meets the floor, so the version branch accepts it. + Mock cmake { + if ($args -contains '--version') { 'cmake version 4.2.0' } + else { 'Generators' } + } + Test-CmakeCanDriveGenerator -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'rejects a sub-4.2 cmake that does not list the VS 2026 generator' { + Mock cmake { + if ($args -contains '--version') { 'cmake version 3.31.0' } + else { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + } + Test-CmakeCanDriveGenerator -Generator 'Visual Studio 18 2026' | Should -BeFalse + } +} + +Describe 'Get-FallbackVsGenerator (older VS the cmake can drive)' { + BeforeAll { + function New-FakeVsTree2 { + param([string]$Root, [string]$VersionDir, [string]$Edition = 'BuildTools') + $clDir = Join-Path $Root "Microsoft Visual Studio\$VersionDir\$Edition\VC\Tools\MSVC\14.39.00000\bin\Hostx64\x64" + New-Item -ItemType Directory -Path $clDir -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $clDir 'cl.exe') -Force | Out-Null + } + } + BeforeEach { + $script:OrigPF = ${env:ProgramFiles} + $script:OrigPFx86 = ${env:ProgramFiles(x86)} + } + AfterEach { + ${env:ProgramFiles} = $script:OrigPF + ${env:ProgramFiles(x86)} = $script:OrigPFx86 + } + + It 'returns the VS 2022 generator when VS 2022 is installed and cmake lists it' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF_fb' + New-FakeVsTree2 -Root $root -VersionDir '2022' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86_fb' + Mock cmake { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + $r = Get-FallbackVsGenerator + $r.Generator | Should -Be 'Visual Studio 17 2022' + } + + It 'returns null when the cmake cannot drive any installed older VS' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF_none' + New-FakeVsTree2 -Root $root -VersionDir '2022' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86_none' + # cmake lists only VS 2026 (not 2022/2019/2017), so no older fallback is usable. + Mock cmake { "Generators`n Visual Studio 18 2026 = Generates VS 2026 project files." } + $r = Get-FallbackVsGenerator + $r | Should -BeNullOrEmpty + } + + It 'falls back to an older VS installed under the Preview edition dir' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF_prev' + New-FakeVsTree2 -Root $root -VersionDir '2022' -Edition 'Preview' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86_prev' + Mock cmake { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + (Get-FallbackVsGenerator).Generator | Should -Be 'Visual Studio 17 2022' + } +} + +Describe 'Deferred build tools (prebuilt path needs no VS/CMake)' { + # Phase-1 detection must be non-fatal (prebuilt path never blocked) and the + # deferred installer must no-op when VS was already detected. The install + + # exit-1 path is covered by studio-windows-no-vs-smoke.yml. + BeforeEach { + $script:OrigPF = ${env:ProgramFiles} + $script:OrigPFx86 = ${env:ProgramFiles(x86)} + } + AfterEach { + ${env:ProgramFiles} = $script:OrigPF + ${env:ProgramFiles(x86)} = $script:OrigPFx86 + $script:VsInstallPath = $null + $script:CmakeGenerator = $null + } + + It 'Find-VsBuildTools returns null when no VS is present (probe stays non-fatal)' { + # Empty discovery roots so no VS is found; the probe must return null + # (then log and continue, never exit). + ${env:ProgramFiles} = (Join-Path $TestDrive 'EmptyPF') + ${env:ProgramFiles(x86)} = (Join-Path $TestDrive 'EmptyPFx86') + New-Item -ItemType Directory -Force -Path ${env:ProgramFiles}, ${env:ProgramFiles(x86)} | Out-Null + Find-VsBuildTools | Should -BeNullOrEmpty + } + + It 'Ensure-BuildToolsForLlamaSourceBuild no-ops when VS is already detected' { + # With $VsInstallPath already set, the deferred installer must return without + # re-scanning or installing. + $script:VsInstallPath = 'C:\Program Files\Microsoft Visual Studio\2022\BuildTools' + $script:CmakeGenerator = 'Visual Studio 17 2022' + { Ensure-BuildToolsForLlamaSourceBuild } | Should -Not -Throw + $script:VsInstallPath | Should -Be 'C:\Program Files\Microsoft Visual Studio\2022\BuildTools' + $script:CmakeGenerator | Should -Be 'Visual Studio 17 2022' + } +} + +Describe 'Source-build ordering invariant: CUDA integration runs AFTER the VS generator is finalized (#6473 review)' { + # Resolve-CudaToolkit copies the CUDA .targets into the current generator's dir, + # so it must run after the VS 2026 gate/fallback; otherwise a fallback to VS 2022 + # builds v170 while the .targets went to v180 ("No CUDA toolset found"). + It 'the source-build Resolve-CudaToolkit call appears AFTER the Get-FallbackVsGenerator fallback' { + $text = Get-Content -Raw -LiteralPath $script:SetupPs1 + $idxFallback = $text.IndexOf('$fallback = Get-FallbackVsGenerator') + $idxResolve = $text.IndexOf('Resolve-CudaToolkit -RequireOrExit') + $idxFallback | Should -BeGreaterThan 0 + $idxResolve | Should -BeGreaterThan 0 + $idxResolve | Should -BeGreaterThan $idxFallback + } +} + +Describe 'Get-FallbackVsGenerator discovery is symmetric with Find-VsBuildTools (#6473 review)' { + # The fallback must also query vswhere, else a VS in a custom location is found + # as primary but missed as fallback -> avoidable hard exit. + It 'queries vswhere as part of fallback discovery' { + $src = Get-FunctionSource -Path $script:SetupPs1 -Name Get-FallbackVsGenerator + $src | Should -Match 'vswhere' + } +} + +Describe 'Test-VCRedistInstalled (VC++ 2015-2022 runtime needed by the prebuilt llama.cpp + PyTorch)' { + # The prebuilts link the VC++ runtime DLLs (which the Universal CRT lacks); + # detection is System32\vcruntime140_1.dll with a registry fallback. + BeforeEach { $script:OrigSysRoot = $env:SystemRoot } + AfterEach { $env:SystemRoot = $script:OrigSysRoot } + + # Probes Test-Path once (System32 DLL), then the registry; mock both. + It 'returns true when vcruntime140_1.dll is present in System32' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $true } + Test-VCRedistInstalled | Should -BeTrue + } + It 'returns true via the registry when the DLL is not found (Installed=1, >= 14.20)' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $false } + Mock Get-ItemProperty { [pscustomobject]@{ Installed = 1; Major = 14; Minor = 29 } } + Test-VCRedistInstalled | Should -BeTrue + } + It 'returns false when neither the DLL nor a >= 14.20 registry entry exists' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $false } + Mock Get-ItemProperty { throw 'no key' } + Test-VCRedistInstalled | Should -BeFalse + } + It 'returns false for an old 2015-only redist (Installed=1 but < 14.20)' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $false } + Mock Get-ItemProperty { [pscustomobject]@{ Installed = 1; Major = 14; Minor = 0 } } + Test-VCRedistInstalled | Should -BeFalse + } +} From 1582d2854c2579d6483e06d0b0ed462d38b98adb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 02:10:35 -0700 Subject: [PATCH 005/306] Harden trust_remote_code consent: scan GGUF-only auto_map and drop pre-set TRC defaults (#6478) * Scan auto_map for GGUF-only repo ids in the consent gate The trust_remote_code consent gate treated any repo classified GGUF-only (ships .gguf, no transformers-loadable weight) as having no remote code, so _config_has_auto_map returned False even when a config declared an auto_map and the repo shipped the referenced .py. The evaluator then skipped the scan/fingerprint for that target entirely. GGUF-inertness is a property of the loader, not the repo. A GGUF selection loads via llama.cpp, which never reads config.json/auto_map, and that case is already short-circuited upstream by the caller's is_gguf check (the inference route skips the remote-code preflight for a GGUF load). Every path that reaches this helper (export, training, non-GGUF inference) loads through transformers/Unsloth from_pretrained, which DOES import auto_map even for a repo that only ships .gguf weights: the custom module runs before from_pretrained fails on the missing transformers weights. The export path has no is_gguf guard and passes the source straight to FastLanguageModel.from_pretrained(trust_remote_code=True), so the in-helper GGUF skip let a repo with config.json (auto_map) + modeling_x.py + only a .gguf run unreviewed code during export. Drop the redundant repo-level GGUF short-circuit (and the now-unused _is_gguf_repo helper). A direct .gguf file reference stays inert via _is_direct_gguf_file_ref because that genuinely is a single-file llama.cpp load; repo ids are always scanned. A GGUF repo whose auto_map ships no .py still allows via the existing empty-code path, so legitimate GGUF loads are unaffected (and GGUF inference never reaches this helper at all). Only a repo that actually contains a .gguf can change behavior here; non-GGUF repos (safetensors, MLX) are byte-identical before and after. Update the GGUF auto_map test to expect a scan, and add two regression tests: a GGUF-only repo shipping auto_map Python is scanned and blocked, and a transformers-style repo (safetensors / MLX .npz) with auto_map stays scanned and blocked. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove trust_remote_code config defaults; consent dialog is the only enabler trust_remote_code is a per-load decision that must go through the remote-code consent dialog, which scans the auto_map code and pins the exact version. Two pre-set paths could still enable it without the user reviewing any code, and the GGUF consent bypass rode one of them into the export flow: - 4 model_defaults YAMLs shipped trust_remote_code: true (GLM-4.7-Flash, Nemotron-3-Nano-30B-A3B, PaddleOCR-VL, ERNIE-4.5-VL). - The frontend consent hook silently enabled trust_remote_code on a clean scan whenever the caller flagged the model as needing it. Remove every trust_remote_code key from the model_defaults YAMLs (the loaders already default to False when the key is absent) and delete the frontend silent auto-enable, so trust_remote_code is only turned on after the user approves the scanned code in the dialog. The three models that genuinely run custom code ship auto_map, which the consent gate detects on its own via _config_has_auto_map, so the dialog still fires for them in inference, training, and export (Nemotron is also re-granted by the trusted-org auto-enable in the workers). GLM-4.7-Flash has no auto_map: glm4_moe_lite is native in transformers 5.0+ and it loads with trust_remote_code=False, so its YAML flag was a no-op. Adds test_yaml_trust_remote_code_removed.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop YAML sections emptied by trust_remote_code removal Removing trust_remote_code from a model YAML whose section had no other key left a bare `inference:` header, which PyYAML parses as None; load_inference_config() then does `model_config.get("inference", {}).get(...)` and crashes on the None. Drop those now-empty section headers (24 model defaults, all the `inference:` section) so callers fall back to family/default inference params, which is the same result those models had before (their only inference override was trust_remote_code). Strengthens test_yaml_trust_remote_code_removed.py to forbid any empty/None top-level section and to load the affected models' inference config end to end. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add sweep asserting every model YAML loads via training + inference paths Loads all model_defaults YAMLs through load_model_defaults (training) and load_inference_config (inference) with the exact .get() access patterns the routes use, so a malformed/None section that crashes either loader is caught. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Assert ex-TRC auto_map models still surface the consent dialog Removing the trust_remote_code YAML default must not suppress the dialog for the models that genuinely run custom code. The dialog is driven by the repo's auto_map (via preflight_remote_code_consent_for_targets -> _config_has_auto_map), not the YAML flag, so Nemotron/PaddleOCR-VL/ERNIE-4.5-VL still require consent; GLM-4.7-Flash (no auto_map) takes no dialog and loads natively. Mocks only the Hub config + .py reader. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in consent-gate changes * Trim comments to be more succinct --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- .../configs/model_defaults/default.yaml | 2 - .../ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml | 4 - .../unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml | 2 - .../tiiuae_Falcon-H1-0.5B-Instruct.yaml | 4 - .../gemma/unsloth_codegemma-7b-bnb-4bit.yaml | 2 - .../gemma/unsloth_functiongemma-270m-it.yaml | 2 - .../gemma/unsloth_gemma-2-27b-bnb-4bit.yaml | 4 - .../gemma/unsloth_gemma-2-2b.yaml | 4 - .../gemma/unsloth_gemma-3-270m-it.yaml | 2 - .../gemma/unsloth_gemma-3-27b-it.yaml | 2 - .../gemma/unsloth_gemma-3-4b-it.yaml | 2 - .../gemma/unsloth_gemma-3-4b-pt.yaml | 2 - .../gemma/unsloth_gemma-3n-E4B-it.yaml | 2 - .../gemma/unsloth_gemma-3n-E4B.yaml | 2 - .../gemma/unsloth_gemma-4-26B-A4B-it.yaml | 2 - .../gemma/unsloth_gemma-4-26B-A4B.yaml | 2 - .../gemma/unsloth_gemma-4-31B-it.yaml | 2 - .../gemma/unsloth_gemma-4-31B.yaml | 2 - .../gemma/unsloth_gemma-4-E2B-it.yaml | 2 - .../gemma/unsloth_gemma-4-E2B.yaml | 2 - .../gemma/unsloth_gemma-4-E4B-it.yaml | 2 - .../gemma/unsloth_gemma-4-E4B.yaml | 2 - .../gpt-oss/unsloth_gpt-oss-120b.yaml | 2 - .../gpt-oss/unsloth_gpt-oss-20b.yaml | 2 - ...oth_granite-4.0-350m-unsloth-bnb-4bit.yaml | 2 - .../granite/unsloth_granite-4.0-h-micro.yaml | 2 - ...unsloth_Llama-3.2-11B-Vision-Instruct.yaml | 2 - .../llama/unsloth_Llama-3.2-1B-Instruct.yaml | 4 - .../llama/unsloth_Llama-3.2-3B-Instruct.yaml | 2 - .../llama/unsloth_Llama-3.3-70B-Instruct.yaml | 2 - .../unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml | 4 - ...h_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml | 4 - .../unsloth_llama-3-8b-Instruct-bnb-4bit.yaml | 4 - .../llama/unsloth_llama-3-8b-bnb-4bit.yaml | 4 - .../llasa/unsloth_Llasa-3B.yaml | 2 - ...Magistral-Small-2509-unsloth-bnb-4bit.yaml | 2 - .../unsloth_Ministral-3-3B-Instruct-2512.yaml | 2 - ...sloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml | 4 - .../unsloth_Mistral-Small-Instruct-2409.yaml | 4 - .../mistral/unsloth_Pixtral-12B-2409.yaml | 2 - ...oth_mistral-7b-instruct-v0.3-bnb-4bit.yaml | 4 - .../unsloth_mistral-7b-v0.3-bnb-4bit.yaml | 4 - .../other/OuteAI_Llama-OuteTTS-1.0-1B.yaml | 2 - .../other/Spark-TTS-0.5B_LLM.yaml | 2 - .../model_defaults/other/sesame_csm-1b.yaml | 4 - .../other/unsloth_GLM-4.7-Flash.yaml | 2 - .../other/unsloth_LFM2-1.2B.yaml | 2 - .../unsloth_Nemotron-3-Nano-30B-A3B.yaml | 2 - .../other/unsloth_PaddleOCR-VL.yaml | 2 - .../unsloth_answerdotai_ModernBERT-large.yaml | 4 - .../other/unsloth_orpheus-3b-0.1-ft.yaml | 2 - .../other/unsloth_tinyllama-bnb-4bit.yaml | 4 - .../other/unsloth_whisper-large-v3.yaml | 4 - .../phi/unsloth_Phi-3-medium-4k-instruct.yaml | 4 - .../phi/unsloth_Phi-3.5-mini-instruct.yaml | 4 - .../model_defaults/phi/unsloth_Phi-4.yaml | 2 - .../imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml | 2 - .../model_defaults/qwen/unsloth_Qwen2-7B.yaml | 4 - .../qwen/unsloth_Qwen2-VL-7B-Instruct.yaml | 2 - .../qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml | 4 - .../qwen/unsloth_Qwen2.5-7B.yaml | 4 - .../unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml | 4 - .../unsloth_Qwen2.5-Coder-14B-Instruct.yaml | 2 - ...th_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml | 4 - ...sloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml | 2 - .../qwen/unsloth_Qwen3-0.6B.yaml | 2 - ...sloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml | 2 - .../qwen/unsloth_Qwen3-14B.yaml | 2 - .../unsloth_Qwen3-30B-A3B-Instruct-2507.yaml | 2 - .../qwen/unsloth_Qwen3-32B.yaml | 2 - .../qwen/unsloth_Qwen3-4B-Instruct-2507.yaml | 2 - .../qwen/unsloth_Qwen3-4B-Thinking-2507.yaml | 2 - ...Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml | 2 - studio/backend/tests/test_consent_gate.py | 84 ++++++++- .../test_yaml_trust_remote_code_removed.py | 163 ++++++++++++++++++ studio/backend/utils/security/consent.py | 53 +----- .../security/hooks/use-remote-code-consent.ts | 6 +- 77 files changed, 250 insertions(+), 250 deletions(-) create mode 100644 studio/backend/tests/test_yaml_trust_remote_code_removed.py diff --git a/studio/backend/assets/configs/model_defaults/default.yaml b/studio/backend/assets/configs/model_defaults/default.yaml index 12566019b8..841e8ba166 100644 --- a/studio/backend/assets/configs/model_defaults/default.yaml +++ b/studio/backend/assets/configs/model_defaults/default.yaml @@ -2,7 +2,6 @@ # Used for models without specific configurations training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -48,7 +47,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 top_p: 0.95 top_k: -1 diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml index 52511c6eaf..734115ec41 100644 --- a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/ERNIE-4.5-21B-A3B-PT training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml index 524a723dc2..1032449e8c 100644 --- a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -49,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml index fa7bd8c1ea..c8e5f35841 100644 --- a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: tiiuae/Falcon-H1-0.5B-Instruct, unsloth/Falcon-H1-0.5B-Instruct training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml index 62836dc0cd..251409c29d 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -45,6 +44,5 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0 top_p: 0.9 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml index f97a842d2a..89b1d7f938 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml index 56f10cdc4f..e3292b5972 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml @@ -2,7 +2,6 @@ # Based on Gemma2_(9B)-Alpaca.ipynb (same defaults for larger models) training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -41,6 +40,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml index a4acbe9262..98fe497912 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/gemma-2-2b-bnb-4bit, google/gemma-2-2b training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml index 455407abf8..bda5471643 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml index 2bcdf67c15..18392568bd 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml index 7c123da0b8..434ac41b46 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml index 492c42812e..5f0a7b26ce 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 2 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml index 23d00df752..dd5ae51ab0 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 1024 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: audio_input: true inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml index bf5e111b7d..e53e163a04 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 2 num_epochs: 0 @@ -45,7 +44,6 @@ logging: audio_input: true inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml index c80506d9f5..ebe344e382 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-26B-A4B-it, unsloth/gemma-4-26B-A4B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml index 9e579be503..fb89a07133 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-26B-A4B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml index cec4ea95e1..4a089992ac 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-31B-it, unsloth/gemma-4-31B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml index 717cdd5e63..ae7524b7c6 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-31B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml index 43e3d78a23..10c1abd8a5 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E2B-it, unsloth/gemma-4-E2B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml index bd86cef751..fb5c1d9dea 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E2B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml index a8ef51836b..189e5dc6b2 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E4B-it, unsloth/gemma-4-E4B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml index 740cc99df5..aa51440b6a 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E4B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml index bd39e70a96..e2d67bcb0b 100644 --- a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml index 839e9a5b75..aa436117a1 100644 --- a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 1024 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml index 9557fc296f..3f2cb84a94 100644 --- a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -47,7 +46,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml index ce73c6a8ee..ab756fe764 100644 --- a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -47,7 +46,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml index d9a75c391d..1a7a91e56f 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml index 2bc3f6f871..7c7bb8dc3e 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-1B-Instruct, unsloth/Llama-3.2-1B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-1B-Instruct-FP8, unsloth/Llama-3.2-1B-Instruct-FP8-Block, unsloth/Llama-3.2-1B-Instruct-FP8-Dynamic training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 5 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml index 82091c7d35..f73b0c09b6 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml index 5a014a63bf..ffefb29e24 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml index 885f7b47fd..cd986a6da1 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Meta-Llama-3.1-8B-bnb-4bit, unsloth/Meta-Llama-3.1-8B-unsloth-bnb-4bit, meta-llama/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-70B, meta-llama/Meta-Llama-3.1-70B, unsloth/Meta-Llama-3.1-405B-bnb-4bit, meta-llama/Meta-Llama-3.1-405B training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml index 1ff06cca6f..55dd3144c6 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", "meta-llama/Meta-Llama-3.1-8B-Instruct", "unsloth/Meta-Llama-3.1-8B-Instruct","RedHatAI/Llama-3.1-8B-Instruct-FP8","unsloth/Llama-3.1-8B-Instruct-FP8-Block","unsloth/Llama-3.1-8B-Instruct-FP8-Dynamic" training: - trust_remote_code: false max_seq_length: 8192 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml index 95ee5ead5c..8c9cb07fb9 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/llama-3-8b-Instruct, meta-llama/Meta-Llama-3-8B-Instruct training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml index a05ac86f43..32441c5674 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/llama-3-8b, meta-llama/Meta-Llama-3-8B training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml index 1f473c3af1..6bba9c9633 100644 --- a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml +++ b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -40,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.2 top_p: 1.2 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml index 5a53bb52eb..f9833ce705 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -49,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 min_p: 0.01 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml index b84f7e1abb..0ba857cd40 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -49,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.15 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml index abdac62c0c..3476f2dd6d 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Mistral-Nemo-Base-2407", "mistralai/Mistral-Nemo-Base-2407", "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", "unsloth/Mistral-Nemo-Instruct-2407", "mistralai/Mistral-Nemo-Instruct-2407", training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml index 149f2a24f1..eda04d21f9 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Mistral-Small-Instruct-2409-bnb-4bit, mistralai/Mistral-Small-Instruct-2409 training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml index 3976cd0aa0..bcd0d20c8c 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml index 55d5dd289b..34a033e32f 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/mistral-7b-instruct-v0.3, mistralai/Mistral-7B-Instruct-v0.3 training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml index 5b24f5b581..98105eaf38 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml @@ -2,7 +2,6 @@ # Based on Mistral_v0.3_(7B)-Alpaca.ipynb # Also applies to: "unsloth/mistral-7b-v0.3", "mistralai/Mistral-7B-v0.3", training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -41,6 +40,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml index 87b94ce67c..72b5b018e1 100644 --- a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml @@ -6,7 +6,6 @@ audio_type: dac training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.4 top_k: 40 top_p: 0.9 diff --git a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml index 03748cd5fd..d20751b0c7 100644 --- a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml +++ b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml @@ -6,7 +6,6 @@ audio_type: bicodec training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -48,7 +47,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.8 top_k: 50 top_p: 1.0 diff --git a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml index 5c1e180f8c..8a80282a2a 100644 --- a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml +++ b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml @@ -5,7 +5,6 @@ audio_type: csm training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -45,6 +44,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml index 6d8be3656f..a973c2d4e4 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/GLM-4.7-Flash-unsloth-bnb-4bit, unsloth/GLM-4.7-Flash-bnb-4bit, THUDM/GLM-4.7-Flash training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 0.7 top_p: 0.8 top_k: 20 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml index 39a2fe0a5b..b0feafbd6e 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -39,7 +38,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.3 min_p: 0.15 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml index 663ce87d5f..2c44c91eab 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -47,7 +46,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 1.0 top_p: 1.0 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml index b7587bbd91..e1fbc08e4d 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -49,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml index cc5d130bfa..2abdfd8ac3 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml @@ -2,7 +2,6 @@ # Based on bert_classification.ipynb training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 1 num_epochs: 0 @@ -41,6 +40,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml index 883761675f..5a3c4abb48 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml @@ -6,7 +6,6 @@ audio_type: snac training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -48,7 +47,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml index 35c850c71f..a6ce27620f 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 1 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml index 9140878e0e..050774a8cd 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml @@ -6,7 +6,6 @@ audio_type: whisper audio_input: true training: - trust_remote_code: false eval_steps: 5 max_seq_length: 448 # num_epochs: 4 @@ -41,6 +40,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml index 1088df7796..c574714d78 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Phi-3-medium-4k-instruct-bnb-4bit", "microsoft/Phi-3-medium-4k-instruct", training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml index 79812a74c4..e803c842b3 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Phi-3.5-mini-instruct-bnb-4bit", "microsoft/Phi-3.5-mini-instruct" training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml index aaa4feac45..4de3d9437d 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.8 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml index fa7b9c4e8b..bb75b3ce52 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml @@ -4,7 +4,6 @@ # MoE model - includes gate_up_proj for MoE layers training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -46,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml index 3e64a6ca48..c305d328c2 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2-7B-bnb-4bit, Qwen/Qwen2-7B training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml index 894751bed1..6cee3d0949 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml index 1d37cc9829..20ba81df2c 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-1.5B-Instruct-unsloth-bnb-4bit, Qwen/Qwen2.5-1.5B-Instruct, unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml index 99f3a66e23..9930786c24 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-7B-unsloth-bnb-4bit, Qwen/Qwen2.5-7B, unsloth/Qwen2.5-7B-bnb-4bit training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml index c48b943cba..775c7ce08f 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit, Qwen/Qwen2.5-Coder-1.5B-Instruct training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml index 830bfcf1cb..856db0c1b3 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml index db88c3b033..5900392547 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-Coder-7B-Instruct, Qwen/Qwen2.5-Coder-7B-Instruct training: - trust_remote_code: false max_seq_length: 32768 # num_epochs: 4 num_epochs: 0 @@ -42,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml index cb9bcb104b..bd54b1d015 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml index 13f066a27d..9feb6dcaae 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 1024 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml index 87c042705b..a40eace253 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml index a8ecbb4365..c130771c32 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml index 485dd7a111..2fb3a95c30 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml @@ -4,7 +4,6 @@ # MoE model - includes gate_up_proj for MoE layers training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -46,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml index 0de64d50ae..152f4ae06a 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml index dc5940d58c..94fe000708 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 top_p: 0.80 top_k: 20 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml index 6392ee0ae9..3c325485d2 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -45,7 +44,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_p: 0.95 top_k: 20 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml index ef52fad763..5b47c3bdd2 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -43,7 +42,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 top_p: 0.8 top_k: 20 diff --git a/studio/backend/tests/test_consent_gate.py b/studio/backend/tests/test_consent_gate.py index 67b44ede89..3237ffea8d 100644 --- a/studio/backend/tests/test_consent_gate.py +++ b/studio/backend/tests/test_consent_gate.py @@ -1224,9 +1224,9 @@ class TestScannerCoversAllExecutableCode: configs = consent._load_remote_code_configs("some/gated-repo") assert configs is None - def test_gguf_repo_auto_map_is_ignored(self): - # A GGUF repo with a vestigial auto_map loads via llama.cpp, which never runs it, - # so _config_has_auto_map must return False and skip the consent flow. + def test_gguf_repo_auto_map_is_scanned_for_non_file_load_paths(self, tmp_path): + # A GGUF-only repo id still hits export paths that run auto_map; only a direct + # .gguf file is inert. def _dl( repo_id = None, filename = None, @@ -1234,10 +1234,8 @@ class TestScannerCoversAllExecutableCode: **kw, ): import json - import tempfile - if filename == "config.json": - p = Path(tempfile.mkdtemp()) / "config.json" + p = tmp_path / "config.json" p.write_text( json.dumps({"auto_map": {"AutoModelForCausalLM": "modeling_decilm.X"}}) ) @@ -1251,7 +1249,79 @@ class TestScannerCoversAllExecutableCode: return_value = ["config.json", "model-00001-of-00097.gguf"], ), ): - assert consent._config_has_auto_map("unsloth/Some-Model-GGUF") is False + assert consent._config_has_auto_map("unsloth/Some-Model-GGUF") is True + + def test_gguf_only_repo_with_python_is_scanned_and_blocked(self, tmp_path): + # Regression: the GGUF-only short-circuit must not skip auto_map Python for export loaders. + def _dl( + repo_id = None, + filename = None, + token = None, + **kw, + ): + import json + + p = tmp_path / filename + if filename == "config.json": + p.write_text(json.dumps({"auto_map": {"AutoModel": "modeling_evil.X"}})) + return str(p) + if filename == "modeling_evil.py": + p.write_text("import subprocess\nsubprocess.Popen(['id'])\n") + return str(p) + raise EntryNotFoundError(filename) + + with ( + patch("huggingface_hub.hf_hub_download", side_effect = _dl), + patch( + "huggingface_hub.list_repo_files", + return_value = ["config.json", "modeling_evil.py", "model.Q4_K_M.gguf"], + ), + ): + d = evaluate_remote_code_consent_for_targets( + ["evil/GGUF-Only"], + trust_remote_code = True, + ) + + assert d.has_remote_code is True + assert d.blocked is True + assert d.max_severity == HIGH + assert d.fingerprint + + def test_transformers_style_repo_auto_map_is_scanned_and_blocked(self, tmp_path): + # A non-GGUF repo (safetensors/MLX) with auto_map is still scanned and blocked. + def _dl( + repo_id = None, + filename = None, + token = None, + **kw, + ): + import json + + p = tmp_path / filename + if filename == "config.json": + p.write_text(json.dumps({"auto_map": {"AutoModel": "modeling_evil.X"}})) + return str(p) + if filename == "modeling_evil.py": + p.write_text("import subprocess\nsubprocess.Popen(['id'])\n") + return str(p) + raise EntryNotFoundError(filename) + + for weights in (["model.safetensors"], ["weights.npz"]): + with ( + patch("huggingface_hub.hf_hub_download", side_effect = _dl), + patch( + "huggingface_hub.list_repo_files", + return_value = ["config.json", "modeling_evil.py", *weights], + ), + ): + d = evaluate_remote_code_consent_for_targets( + ["org/Transformers-Style"], + trust_remote_code = True, + ) + assert d.has_remote_code is True, weights + assert d.blocked is True, weights + assert d.max_severity == HIGH, weights + assert d.fingerprint, weights def test_direct_gguf_file_reference_has_no_auto_map(self): # A direct .gguf file reference (repo id + filename, >=3 segments) is a GGUF load: no remote code, no Hub call. diff --git a/studio/backend/tests/test_yaml_trust_remote_code_removed.py b/studio/backend/tests/test_yaml_trust_remote_code_removed.py new file mode 100644 index 0000000000..9578f08420 --- /dev/null +++ b/studio/backend/tests/test_yaml_trust_remote_code_removed.py @@ -0,0 +1,163 @@ +# 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: model-default YAMLs must not pre-set trust_remote_code. + +It is a per-load decision made through the consent dialog (which scans and pins the +auto_map code), never a config default -- a YAML flag would re-open the no-review +bypass. Models that run custom code ship auto_map, so the dialog still fires without it. +""" + +from pathlib import Path + +import yaml + +_CONFIGS = Path(__file__).resolve().parent.parent / "assets" / "configs" +_MODEL_DEFAULTS = _CONFIGS / "model_defaults" + + +def test_no_model_default_yaml_sets_trust_remote_code(): + offenders = [] + for f in _MODEL_DEFAULTS.rglob("*.yaml"): + doc = yaml.safe_load(f.read_text()) or {} + if not isinstance(doc, dict): + continue + for section, body in doc.items(): + if isinstance(body, dict) and "trust_remote_code" in body: + offenders.append( + f"{f.relative_to(_CONFIGS)} [{section}={body['trust_remote_code']}]" + ) + assert not offenders, ( + "trust_remote_code must not be pre-set in model defaults; it is enabled only via " + f"the consent dialog. Remove it from: {offenders}" + ) + + +def test_no_model_default_yaml_has_empty_or_none_section(): + # A bare `inference:` header (no keys) parses to None and crashes the .get() loaders. + offenders = [] + for f in _MODEL_DEFAULTS.rglob("*.yaml"): + doc = yaml.safe_load(f.read_text()) + if not isinstance(doc, dict): + offenders.append(f"{f.relative_to(_CONFIGS)} (not a mapping)") + continue + for section, body in doc.items(): + if body is None or (isinstance(body, dict) and not body): + offenders.append(f"{f.relative_to(_CONFIGS)} [{section}]") + assert not offenders, ( + "empty/None YAML section would crash the config loaders; drop the bare section " + f"header instead. Offending: {offenders}" + ) + + +def test_formerly_flagged_models_load_inference_config_without_crash(): + # Models whose inference section was emptied by the TRC removal must still load. + from utils.inference import load_inference_config + for model in ( + "tiiuae/Falcon-H1-0.5B-Instruct", + "unsloth/Llama-3.2-1B-Instruct", + "unsloth/Qwen2.5-7B", + ): + cfg = load_inference_config(model) + assert isinstance(cfg, dict) + assert cfg.get("trust_remote_code", False) is False + + +def test_all_model_yamls_load_for_training_and_inference(): + # Every YAML must load through both config paths (training + inference) as the routes do. + from utils.inference import load_inference_config + from utils.models.model_config import load_model_defaults + + infer_keys = { + "temperature", + "top_p", + "top_k", + "min_p", + "presence_penalty", + "trust_remote_code", + } + failures = [] + for f in sorted(_MODEL_DEFAULTS.rglob("*.yaml")): + stem = f.stem + try: + md = load_model_defaults(stem) + assert isinstance(md, dict), f"load_model_defaults -> {type(md).__name__}" + assert not [k for k, v in md.items() if v is None], "has a None section" + # the dict sections the loaders read via .get('sect', {}).get(...) + for sect in ("training", "inference", "lora", "logging"): + assert isinstance(md.get(sect, {}), dict), f"{sect!r} is not a mapping" + md.get("training", {}).get("trust_remote_code", False) # routes/training.py:263 + cfg = load_inference_config(stem) + assert infer_keys <= set(cfg), f"inference config missing {infer_keys - set(cfg)}" + except Exception as e: # noqa: BLE001 - aggregate so one failure does not hide others + failures.append(f"{f.relative_to(_CONFIGS)}: {type(e).__name__}: {e}") + assert not failures, "YAML config loaders crashed on: " + "; ".join(failures) + + +def test_base_templates_have_no_trust_remote_code(): + for name in ("full_finetune.yaml", "lora_text.yaml", "vision_lora.yaml"): + doc = yaml.safe_load((_CONFIGS / name).read_text()) or {} + flat = yaml.safe_dump(doc) + assert "trust_remote_code" not in flat, f"{name} should not set trust_remote_code" + + +def test_loader_defaults_trust_remote_code_off_for_formerly_flagged_models(): + # The 4 models that used to ship trust_remote_code: true must now report no default. + from utils.models.model_config import load_model_defaults + for model in ( + "unsloth/GLM-4.7-Flash", + "unsloth/Nemotron-3-Nano-30B-A3B", + "unsloth/PaddleOCR-VL", + "unsloth/ERNIE-4.5-VL-28B-A3B-PT", + ): + d = load_model_defaults(model) + for section in ("training", "inference"): + assert not (d.get(section) or {}).get( + "trust_remote_code", False + ), f"{model} [{section}] still carries a trust_remote_code default" + + +def test_formerly_flagged_auto_map_models_still_require_consent_dialog(): + # Crux: an auto_map model must STILL surface the dialog (driven by auto_map, not the + # YAML flag). Real backend path, mocking only the Hub json + .py fetch. + from unittest.mock import patch + from utils.security import consent, preflight_remote_code_consent_for_targets + + auto_map_cfg = [ + { + "auto_map": { + "AutoConfig": "configuration_x.XConfig", + "AutoModelForCausalLM": "modeling_x.XForCausalLM", + } + } + ] + benign_py = {"modeling_x.py": "class XForCausalLM:\n pass\n"} + for model in ( + "unsloth/Nemotron-3-Nano-30B-A3B", + "unsloth/PaddleOCR-VL", + "unsloth/ERNIE-4.5-VL-28B-A3B-PT", + ): + with ( + patch.object(consent, "_load_remote_code_configs", return_value = auto_map_cfg), + patch.object(consent, "repo_remote_code_files", return_value = benign_py), + ): + decision = preflight_remote_code_consent_for_targets([model], hf_token = None) + # routes/models.py opens the dialog from decision.has_remote_code. + assert decision.has_remote_code is True, ( + f"{model} ships auto_map but the consent scan did not flag it -> dialog would " + "not fire" + ) + + +def test_no_auto_map_model_takes_no_dialog(): + # Flip side: GLM-4.7-Flash ships no auto_map -> no dialog; its old YAML flag was a no-op. + from unittest.mock import patch + from utils.security import consent, preflight_remote_code_consent_for_targets + + with patch.object( + consent, "_load_remote_code_configs", return_value = [{"model_type": "glm4_moe_lite"}] + ): + decision = preflight_remote_code_consent_for_targets( + ["unsloth/GLM-4.7-Flash"], hf_token = None + ) + assert decision.has_remote_code is False diff --git a/studio/backend/utils/security/consent.py b/studio/backend/utils/security/consent.py index d75da37971..475f8dabbb 100644 --- a/studio/backend/utils/security/consent.py +++ b/studio/backend/utils/security/consent.py @@ -85,8 +85,13 @@ def _config_has_auto_map(model_name: str, hf_token: Optional[str] = None) -> Opt """Whether any config (model/tokenizer/processor) declares an ``auto_map`` the load would execute. Reads raw JSON with ``hf_token``; returns None when a config is unreadable (transient/auth) so the caller treats it as "unknown" and scans, False - when the repo genuinely ships none. GGUF is False (llama.cpp never runs auto_map); - this is the single chokepoint for that rule, shared by validate / scan / worker. + when the repo genuinely ships none. + + GGUF-inertness is the LOADER's property, decided upstream by the caller's ``is_gguf`` + check, not here. Every path that reaches this helper (export, training, non-GGUF + inference) loads via ``from_pretrained``, which imports ``auto_map`` even for a + ``.gguf``-only repo, so a GGUF-classified repo id MUST still be scanned. Only a direct + ``.gguf`` FILE reference is inert (a genuine single-file llama.cpp load). """ # A direct .gguf FILE loads via llama.cpp (auto_map inert). A bare repo id ending in # .gguf can still ship safetensors + auto_map, so it falls through to the scan. @@ -97,11 +102,6 @@ def _config_has_auto_map(model_name: str, hf_token: Optional[str] = None) -> Opt return None if not any(bool((cfg or {}).get("auto_map")) for cfg in configs): return False - # auto_map present but a GGUF repo -> inert. Checked only when auto_map exists, so - # normal models skip the extra listing. - if _is_gguf_repo(model_name, hf_token): - logger.debug("Ignoring auto_map for GGUF repo '%s' (llama.cpp never runs it).", model_name) - return False return True @@ -124,42 +124,6 @@ def _is_direct_gguf_file_ref(model_name: str) -> bool: return name.count("/") >= 2 -# Weight formats transformers can load (and thus run auto_map for). A repo shipping any -# of these is not GGUF-only -- the user could load it through transformers -- so consent -# still applies even if it also ships a .gguf. -_TRANSFORMERS_WEIGHT_SUFFIXES = ( - ".safetensors", - ".bin", - ".pt", - ".pth", - ".h5", - ".msgpack", - ".onnx", - ".ckpt", -) - - -def _is_gguf_repo(model_name: str, hf_token: Optional[str] = None) -> bool: - """Whether a remote repo loads only through llama.cpp (GGUF weights and NO - transformers-loadable weights), making its config inert. A repo that also ships - transformers weights is NOT GGUF (auto_map could run, so still gate). A listing - failure is treated as "not known-GGUF" (fall through to scan). - """ - try: - from utils.paths import is_local_path - - if is_local_path(model_name): - return False - from huggingface_hub import list_repo_files - - files = [f.lower() for f in list_repo_files(model_name, token = hf_token)] - has_gguf = any(f.endswith(".gguf") for f in files) - has_transformers_weights = any(f.endswith(_TRANSFORMERS_WEIGHT_SUFFIXES) for f in files) - return has_gguf and not has_transformers_weights - except Exception: - return False - - def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -> Optional[list]: """Read every config that can declare ``auto_map`` (model/tokenizer/processor) as raw dicts. Returns the configs present (``[]`` when all 404, a definitive "no @@ -300,8 +264,7 @@ def evaluate_remote_code_consent_for_targets( ) if not combined: - # auto_map declared but no executable .py (e.g. a GGUF repo's vestigial - # auto_map) -> nothing to run -> allow. + # auto_map declared but no executable .py (e.g. GGUF repo) -> nothing to scan -> allow. return RemoteCodeDecision( primary, False, diff --git a/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts b/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts index 5e59ec5214..363c8249c7 100644 --- a/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts +++ b/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts @@ -45,11 +45,9 @@ export async function confirmRemoteCodeIfNeeded({ }; } - // Open the dialog for custom-code consent OR flagged unsafe files. Otherwise a model - // can still need trust_remote_code via its YAML default (no auto_map, e.g. GLM-4.7-Flash): - // propagate the caller's requirement with an empty pin instead of sending false. + // No custom code and nothing unsafe: proceed without trust_remote_code. Models needing + // it ship auto_map and hit the dialog below, so the flag is only enabled via approval. if (!scan.requiresTrustRemoteCode && scan.unsafeFiles.length === 0) { - if (requiresTrustRemoteCode) onApprove(null); return true; } From 4e8d0da8f963165215b3d04cb4e0a1ce6f6dcf64 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 02:11:18 -0700 Subject: [PATCH 006/306] Show model provider/org in the trust_remote_code consent dialog (#6537) * Show model provider/org in the trust_remote_code consent dialog The consent dialog showed only the trailing model name (modelName.split('/').pop()), dropping the owner. The HF org/owner is the 'who do I trust' signal the prompt is asking about, so render it: 'NVIDIA-Nemotron-3-Nano-4B from "unsloth"'. A null provider for local paths and bare names leaves those renders unchanged. Applies to the enable/blocked/malware variants (shared description block). * Only show consent provider tag for a confident single Hub repo Tighten parseModelDisplay so the 'from ""' tag is shown only for a canonical owner/repo Hub id (exactly one slash, both segments non-empty) that is not a local path and not part of a multi-repo scan. This avoids misattributing a relative local directory name (models/llama/7b) or a LoRA base/external repo's finding to the wrong publisher in a trust decision. Extract a ProviderSuffix component so both description branches render the clause identically via &&. * Tighten consent provider-tag comments * Source the consent provider tag from the backend The dialog inferred the provider client-side from the model id, using scanCreatedRepos (a cleanup-only list) to detect multi-repo scope and a regex that missed bare relative paths like a local owner/model dir. Both could attribute the scanned code to the wrong publisher. Move the decision to the backend, where locality and scan scope are known: _consent_provider returns the owner only for a single, non-local, canonical owner/repo Hub id, and the route returns it as payload[provider]. The frontend now renders scan.provider directly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Suppress consent provider tag when external auto_map code is scanned A single Hub repo can declare an auto_map that loads code from another repo (owner/other--module.Class). The scanner fingerprints that external repo's Python, but security_targets still held only the primary, so the dialog attributed the custom code to the primary publisher. Pass the external refs collected during the scan to _consent_provider and return no provider when any are present, so attribution is shown only for genuinely self-contained repos. * Trim comments to be more succinct --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/routes/models.py | 21 ++++++++++++ studio/backend/tests/test_consent_gate.py | 33 +++++++++++++++++++ .../features/security/api/remote-code-api.ts | 2 ++ .../components/remote-code-consent-dialog.tsx | 27 +++++++++++++-- .../security/hooks/use-remote-code-consent.ts | 1 + .../frontend/src/features/security/types.ts | 1 + 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index c17bb6fb57..1e567774ac 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -1582,6 +1582,23 @@ async def get_model_config( ) +def _consent_provider( + model_name: str, + scanned_targets: List[str], + external_refs: Optional[List[str]] = None, +) -> Optional[str]: + """HF org for the consent dialog's `from ""` tag, or None. + + Returns the owner only for a single, non-local, canonical ``owner/repo`` id; a LoRA's + extra base, a local path, or an external ``auto_map`` ref yields None so the dialog + never misattributes scanned code. + """ + if len(scanned_targets) != 1 or external_refs or is_local_path(model_name): + return None + parts = model_name.split("/") + return parts[0] if len(parts) == 2 and all(parts) else None + + @router.post("/remote-code-scan") async def scan_model_remote_code( model_name: str = Body(..., embed = True), @@ -1645,12 +1662,14 @@ async def scan_model_remote_code( except Exception: pass + external_refs: list = [] for _target in security_targets: # Use the pre-base-resolution snapshot for the primary (see above). _mark_scan_created( _target, preexisting = _primary_preexisting if _target == model_name else None ) for _ext in external_auto_map_repos(_target, hf_token): + external_refs.append(_ext) _mark_scan_created(_ext) decision = preflight_remote_code_consent_for_targets(security_targets, hf_token = hf_token) payload = decision.response_payload() @@ -1658,6 +1677,8 @@ async def scan_model_remote_code( # created_by_scan = primary flag (older clients); scan_created_repos drives cleanup. payload["created_by_scan"] = model_name in scan_created_repos payload["scan_created_repos"] = scan_created_repos + # Provider tag decided here, where locality/scan scope/external refs are known. + payload["provider"] = _consent_provider(model_name, security_targets, external_refs) # Malware gate (metadata-only): surface HF-flagged unsafe files so the dialog can # hard-block. Orthogonal to remote code -- a poisoned pickle needs no auto_map. diff --git a/studio/backend/tests/test_consent_gate.py b/studio/backend/tests/test_consent_gate.py index 3237ffea8d..0fc8d7b695 100644 --- a/studio/backend/tests/test_consent_gate.py +++ b/studio/backend/tests/test_consent_gate.py @@ -815,6 +815,39 @@ class TestRemoteCodeScan: assert not scan_remote_code_files({"modeling_x.py": _SCAN_MALICIOUS}).clean +class TestConsentProvider: + """_consent_provider attributes the dialog's `from ""` tag only when safe.""" + + @staticmethod + def _fn(): + from routes.models import _consent_provider + return _consent_provider + + def test_single_hub_id_returns_owner(self): + assert self._fn()("NVIDIA/Nemotron", ["NVIDIA/Nemotron"]) == "NVIDIA" + assert self._fn()("NVIDIA/Nemotron", ["NVIDIA/Nemotron"], []) == "NVIDIA" + + def test_multi_target_lora_returns_none(self): + # A LoRA scans adapter + base; attributing to one would mislead. + assert self._fn()("user/adapter", ["user/adapter", "NVIDIA/base"]) is None + + def test_external_auto_map_ref_returns_none(self): + # A single repo whose auto_map pulls code from another repo: don't attribute it. + assert self._fn()("owner/repo", ["owner/repo"], ["evilorg/evilrepo"]) is None + + def test_local_path_returns_none(self, tmp_path): + d = tmp_path / "org" / "model" + d.mkdir(parents = True) + assert self._fn()(str(d), [str(d)]) is None + assert self._fn()("/home/me/model", ["/home/me/model"]) is None + + def test_non_canonical_id_returns_none(self): + fn = self._fn() + assert fn("a/b/c", ["a/b/c"]) is None + assert fn("/repo", ["/repo"]) is None + assert fn("plainname", ["plainname"]) is None + + class TestScannerCoversAllExecutableCode: """repo_remote_code_files must collect every .py the loader could execute, so the fingerprint can't certify unscanned code.""" diff --git a/studio/frontend/src/features/security/api/remote-code-api.ts b/studio/frontend/src/features/security/api/remote-code-api.ts index 8c22ec0f11..0e9a75cc0c 100644 --- a/studio/frontend/src/features/security/api/remote-code-api.ts +++ b/studio/frontend/src/features/security/api/remote-code-api.ts @@ -39,6 +39,7 @@ interface RemoteCodeScanResponse { scan_created_repos?: string[]; unsafe_files?: Array<{ path?: string; level?: string }>; security_blocked?: boolean; + provider?: string | null; } /** Scan a model's auto_map code for the consent dialog (backend reads config + repo @@ -93,6 +94,7 @@ export async function getRemoteCodeScan( (data.created_by_scan ? [data.model_name ?? modelName] : []), unsafeFiles, securityBlocked: Boolean(data.security_blocked), + provider: data.provider ?? null, }; } diff --git a/studio/frontend/src/features/security/components/remote-code-consent-dialog.tsx b/studio/frontend/src/features/security/components/remote-code-consent-dialog.tsx index 8f3616553b..6426225e7f 100644 --- a/studio/frontend/src/features/security/components/remote-code-consent-dialog.tsx +++ b/studio/frontend/src/features/security/components/remote-code-consent-dialog.tsx @@ -161,6 +161,24 @@ function FindingCard({ finding }: { finding: RemoteCodeFinding }) { ); } +/** Last path segment of the model id, for display. */ +function modelDisplayName(modelName?: string): string { + if (!modelName) return "This model"; + return modelName.split("/").pop() || modelName; +} + +/** ` from ""` clause, rendered only when a provider was resolved. */ +function ProviderSuffix({ provider }: { provider: string | null }) { + if (!provider) return null; + return ( + <> + {" "} + from{" "} + "{provider}" + + ); +} + /** App-wide consent dialog for trust_remote_code loads: shows scan findings with the * flagged code in context; CRITICAL is a hard block. Mounted once in the root layout. */ export function RemoteCodeConsentDialog() { @@ -168,7 +186,8 @@ export function RemoteCodeConsentDialog() { const scan = useRemoteCodeConsentDialogStore((s) => s.scan); const resolve = useRemoteCodeConsentDialogStore((s) => s.resolve); - const displayName = scan?.modelName?.split("/").pop() || "This model"; + const displayName = modelDisplayName(scan?.modelName); + const provider = scan?.provider ?? null; const blocked = scan ? !scan.approvable : false; const findings = scan?.findings ?? []; const unsafeFiles = scan?.unsafeFiles ?? []; @@ -218,7 +237,8 @@ export function RemoteCodeConsentDialog() { <> {displayName} - {" "} + + {" "} contains files that Hugging Face's security scan flagged as unsafe (for example, a malicious pickle that would run code when the model loads). It cannot be loaded. The flagged @@ -228,7 +248,8 @@ export function RemoteCodeConsentDialog() { <> {displayName} - {" "} + + {" "} declares custom Python code in its repository.{" "} {blocked ? "A security scan flagged CRITICAL issues, so it cannot be enabled." diff --git a/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts b/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts index 363c8249c7..68045559b3 100644 --- a/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts +++ b/studio/frontend/src/features/security/hooks/use-remote-code-consent.ts @@ -42,6 +42,7 @@ export async function confirmRemoteCodeIfNeeded({ scanCreatedRepos: [], unsafeFiles: [], securityBlocked: false, + provider: null, }; } diff --git a/studio/frontend/src/features/security/types.ts b/studio/frontend/src/features/security/types.ts index f11dbdd38b..0c850c3bfe 100644 --- a/studio/frontend/src/features/security/types.ts +++ b/studio/frontend/src/features/security/types.ts @@ -43,4 +43,5 @@ export interface RemoteCodeScan { scanCreatedRepos: string[]; unsafeFiles: UnsafeFile[]; // files HF flagged unsafe; non-empty => hard block securityBlocked: boolean; // blocked specifically by the malware gate + provider: string | null; // HF org for the "from " tag; null when unattributable } From 307455762c494712e6af438da362292645b256f3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 02:11:33 -0700 Subject: [PATCH 007/306] Use uppercase -GGUF suffix for GGUF export default names (#6538) * Use uppercase -GGUF suffix for GGUF export default names Match the HF/Unsloth GGUF repo convention (Model-GGUF). Default export save dir '-gguf' -> '-GGUF', the local-path sibling dir token '_gguf' -> '_GGUF', and the model-name placeholder 'my-model-gguf' -> 'my-model-GGUF'. Pre-filled defaults only; no backend/path logic change. * Keep local GGUF sibling dir lowercase to match backend cleanup The uppercase change to siblingGgufDirectory diverged from the backend's hard-coded intermediate '_gguf' dir (core/export/export.py), which the export relocates GGUFs out of and then deletes. With the user's save dir defaulting to '_GGUF', that no-longer-equal lowercase sibling would be relocated and removed, which can delete an existing export. Revert the sibling default to '_gguf'; the user-facing GGUF export name (buildRelativeSaveDirectory) keeps the uppercase -GGUF token. * Tighten GGUF sibling-dir comment * Trim comments to be more succinct --------- Co-authored-by: Daniel Han --- .../src/features/export/components/export-run-panel.tsx | 2 +- studio/frontend/src/features/export/export-page.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/export/components/export-run-panel.tsx b/studio/frontend/src/features/export/components/export-run-panel.tsx index ebd35c2158..9e0edf7303 100644 --- a/studio/frontend/src/features/export/components/export-run-panel.tsx +++ b/studio/frontend/src/features/export/components/export-run-panel.tsx @@ -320,7 +320,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { Model Name onModelNameChange(e.target.value)} /> diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 14022f4d19..4d3cfad5dc 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -84,7 +84,7 @@ function buildRelativeSaveDirectory( ): string { if (exportMethod === "gguf") { return `${(sourceBaseModelName.split("/").pop() ?? selectedModelIdx ?? "model") - .replace(/[^a-zA-Z0-9._-]/g, "-")}-gguf`; + .replace(/[^a-zA-Z0-9._-]/g, "-")}-GGUF`; } return `${selectedModelIdx ?? "model"}/${checkpoint}`; } @@ -93,6 +93,8 @@ function siblingGgufDirectory(sourcePath: string): string | null { const trimmed = sourcePath.trim().replace(/[\\/]+$/, ""); if (!trimmed) return null; const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + // Lowercase `_gguf` matches the backend's intermediate dir (core/export/export.py); + // `_GGUF` would relocate+delete that sibling. if (slash < 0) return `${trimmed}_gguf`; const parent = slash === 0 || (slash === 2 && /^[A-Za-z]:/.test(trimmed)) From f89c829fcf3135c3b625b4eebad0fe7e59babfd1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 02:11:46 -0700 Subject: [PATCH 008/306] Fix save crash for legacy list-form _tied_weights_keys (NemotronH) (#6540) * Fix save crash for legacy list-form _tied_weights_keys (NemotronH) transformers >= 5 save_pretrained reads module._tied_weights_keys.keys(), which raises 'list' object has no attribute 'keys' for modules that still declare the attribute as a list (e.g. NemotronH backbone.layers.N.mixer.*_proj), crashing GGUF export and merged saves part-way through. Coerce any legacy list/tuple _tied_weights_keys into the dict form transformers 5.x expects, mapping each key to itself. Only the keys are read (as dedup patterns) so behaviour is preserved, and older transformers that iterate the attribute directly see the same keys. The helper is idempotent and best-effort so a save never fails over it. Called from unsloth_save_model, unsloth_save_pretrained_gguf and unsloth_generic_save after tokenizer patching. Adds version-independent unit tests covering list/tuple coercion, dict and None/empty pass-through, idempotency and odd-object tolerance. * Coerce empty/set _tied_weights_keys too transformers only skips _tied_weights_keys when it is None, so an empty list, tuple or set still reaches .keys() and raises the same AttributeError. Coerce every non-dict container (including the empty case and sets) to a dict, and add tests for empty/set inputs. * Tighten comments in tied-weights save fix * Scope tied-weights-keys coercion to the save call Coercing legacy list-form _tied_weights_keys to {k: k} fixed the transformers 5 save crash, but persisted a self-mapping on the live model. transformers 5 re-ties from the dict's values, so a later resize/re-tie would no-op the tie instead of pointing the output weights back at the input embeddings. Replace the in-place mutation with a decorator that coerces before the save and restores the originals afterwards (including on exception), so the save sees the dict form transformers needs while the model keeps its original tie metadata. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim comments to be more succinct --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../test_normalize_tied_weights_keys.py | 116 ++++++++++++++++++ unsloth/save.py | 59 +++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/saving/test_normalize_tied_weights_keys.py diff --git a/tests/saving/test_normalize_tied_weights_keys.py b/tests/saving/test_normalize_tied_weights_keys.py new file mode 100644 index 0000000000..d18c9877ea --- /dev/null +++ b/tests/saving/test_normalize_tied_weights_keys.py @@ -0,0 +1,116 @@ +"""Unit tests for the tied-weights-keys coercion used by unsloth.save. + +Regression for the NemotronH save / GGUF-export crash: transformers >= 5 +``save_pretrained`` reads ``_tied_weights_keys.keys()`` and raises on the legacy list +form. Exercised on tiny module trees, no model download. +""" + +import pytest +import torch + +from unsloth.save import ( + _coerce_tied_weights_keys_to_dict, + _normalize_tied_weights_keys_for_save, + _restore_tied_weights_keys, +) + + +def _build_tree(): + root = torch.nn.Module() + mixer = torch.nn.Module() + root.add_module("mixer", mixer) + return root, mixer + + +def test_list_becomes_dict_and_restores(): + root, mixer = _build_tree() + mixer._tied_weights_keys = ["q_proj.weight", "o_proj.weight"] + originals = _coerce_tied_weights_keys_to_dict(root) + assert mixer._tied_weights_keys == { + "q_proj.weight": "q_proj.weight", + "o_proj.weight": "o_proj.weight", + } + _restore_tied_weights_keys(originals) + assert mixer._tied_weights_keys == ["q_proj.weight", "o_proj.weight"] + + +def test_tuple_and_set_become_dict(): + root, mixer = _build_tree() + root._tied_weights_keys = ("lm_head.weight",) + mixer._tied_weights_keys = {"q_proj.weight"} + _coerce_tied_weights_keys_to_dict(root) + assert root._tied_weights_keys == {"lm_head.weight": "lm_head.weight"} + assert mixer._tied_weights_keys == {"q_proj.weight": "q_proj.weight"} + + +def test_empty_containers_become_dict(): + root, mixer = _build_tree() + root._tied_weights_keys = [] + mixer._tied_weights_keys = () + _coerce_tied_weights_keys_to_dict(root) + # transformers skips only None; an empty list still hits .keys(). + assert root._tied_weights_keys == {} and mixer._tied_weights_keys == {} + + +def test_none_and_existing_dict_are_left_unchanged(): + root, mixer = _build_tree() + root._tied_weights_keys = None + original = {"a.weight": "b.weight"} + mixer._tied_weights_keys = original + originals = _coerce_tied_weights_keys_to_dict(root) + assert root._tied_weights_keys is None + assert mixer._tied_weights_keys is original # untouched, not rebuilt + assert originals == [] # nothing to restore + + +def test_model_without_modules_method_does_not_raise(): + class NoModules: + pass + + assert _coerce_tied_weights_keys_to_dict(NoModules()) == [] + + +def test_decorator_coerces_during_save_then_restores(): + root, mixer = _build_tree() + mixer._tied_weights_keys = ["lm_head.weight"] + seen = {} + + @_normalize_tied_weights_keys_for_save + def save(model): + seen["keys"] = dict(model.mixer._tied_weights_keys) + return "ok" + + assert save(root) == "ok" + # Dict form was visible to the save, list form restored afterwards. + assert seen["keys"] == {"lm_head.weight": "lm_head.weight"} + assert mixer._tied_weights_keys == ["lm_head.weight"] + + +def test_decorator_restores_on_exception(): + root, mixer = _build_tree() + mixer._tied_weights_keys = ["lm_head.weight"] + + @_normalize_tied_weights_keys_for_save + def save(model): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + save(root) + assert mixer._tied_weights_keys == ["lm_head.weight"] + + +def test_decorator_finds_model_in_kwargs_and_positional(): + # unsloth_save_model / unsloth_generic_save pass model= as a keyword; the gguf path + # binds it as the first positional (method ``self``). Both must be coerced. + for call in (lambda f, r: f(model = r), lambda f, r: f(r)): + root, mixer = _build_tree() + mixer._tied_weights_keys = ["w.weight"] + captured = {} + + @_normalize_tied_weights_keys_for_save + def save(model): + captured["dict"] = isinstance(model.mixer._tied_weights_keys, dict) + + call(save, root) + assert captured["dict"] is True + assert mixer._tied_weights_keys == ["w.weight"] diff --git a/unsloth/save.py b/unsloth/save.py index a6cc665d3e..0e8c8cdc2d 100644 --- a/unsloth/save.py +++ b/unsloth/save.py @@ -44,6 +44,7 @@ import json import shutil import pickle import gc +import functools from transformers.models.llama.modeling_llama import logger from .kernels import fast_dequantize, QUANT_STATE, get_lora_parameters_bias import subprocess @@ -489,6 +490,62 @@ def _qwen3_5_vlm_state_dict_for_save(state_dict): return remapped_state_dict +def _coerce_tied_weights_keys_to_dict(model): + """Coerce each module's legacy list/tuple/set ``_tied_weights_keys`` to dict form, + returning ``[(module, original), ...]`` for the caller to restore. + + transformers >= 5 ``save_pretrained`` reads ``_tied_weights_keys.keys()``, so a model + still declaring it as a list (e.g. NemotronH) crashes mid-save. + """ + originals = [] + try: + modules = list(model.modules()) + except Exception: + return originals + for module in modules: + keys = getattr(module, "_tied_weights_keys", None) + if isinstance(keys, (list, tuple, set)): + try: + module._tied_weights_keys = {k: k for k in keys} + originals.append((module, keys)) + except Exception: + pass + return originals + + +def _restore_tied_weights_keys(originals): + """Undo _coerce_tied_weights_keys_to_dict.""" + for module, keys in originals: + try: + module._tied_weights_keys = keys + except Exception: + pass + + +def _normalize_tied_weights_keys_for_save(save_fn): + """Coerce legacy list-form ``_tied_weights_keys`` to dict for the duration of a save, + then restore: transformers >= 5 re-ties from the dict's *values*, so a persisted + ``{k: k}`` self-map would no-op a later resize/re-tie. ``model`` is the first positional + arg (bound-method ``self``) or the ``model=`` keyword. + """ + + @functools.wraps(save_fn) + def wrapper(*args, **kwargs): + model = kwargs.get("model") + if model is None and args: + model = args[0] + if model is None: + model = kwargs.get("self") + originals = _coerce_tied_weights_keys_to_dict(model) if model is not None else [] + try: + return save_fn(*args, **kwargs) + finally: + _restore_tied_weights_keys(originals) + + return wrapper + + +@_normalize_tied_weights_keys_for_save @torch.inference_mode def unsloth_save_model( model, @@ -2092,6 +2149,7 @@ def push_to_ollama(tokenizer, gguf_location, username: str, model_name: str, tag print("Successfully pushed to ollama") +@_normalize_tied_weights_keys_for_save def unsloth_save_pretrained_gguf( self, save_directory: Union[str, os.PathLike], @@ -2968,6 +3026,7 @@ def save_to_gguf_generic( return metadata +@_normalize_tied_weights_keys_for_save @torch.inference_mode def unsloth_generic_save( model, From 378e33c8a589d1e006520f1b78f3ef083bab4314 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 02:20:08 -0700 Subject: [PATCH 009/306] Studio macOS: faster startup, MLX self-heal, drop obsolete prebuilt pins (#6494) * Studio: defer llama.cpp update probes and self-heal MLX on macOS Two macOS startup problems shared one root area in the FastAPI lifespan: - The llama.cpp capability + freshness probes ran inline before the server yielded, so a cold/slow/flaky network on the GitHub freshness check blocked 'Application startup complete' (~34s on CI, longer in the field). Move both probes to a daemon thread; app.state stays None until ready (status routes already re-probe at request time). Opt out with UNSLOTH_DISABLE_UPDATE_CHECK=1. - Train and Export were greyed out because mlx/mlx-lm/mlx-vlm arrive only transitively and a resolver backtrack silently drops them, so CHAT_ONLY stayed true. Add utils/mlx_repair.py: when Apple Silicon is detected without MLX, reinstall mlx/mlx-lm/mlx-vlm by name on a daemon thread and re-run hardware detection (opt out UNSLOTH_DISABLE_MLX_AUTOREPAIR=1). Surface a chat_only_reason in /api/health plus a sidebar tooltip so a greyed Train/Export explains itself instead of failing silently. * Studio: guard model defaults against a None model name load_model_defaults(None) called model_name.lower() with no guard, raising 'Error loading model defaults for None' before any model is selected. Return an empty dict for a falsy/non-str name. * Studio: drop obsolete upstream macOS + Windows Blackwell prebuilt pins Both pins worked around gaps in ggml-org upstream prebuilts, but Studio now routes every GPU host and all of macOS to the unslothai/llama.cpp fork (published_repo_for_host), which ships the needed bundles, so both pins are dead code on the default install path: - macOS b9415: macOS always routes to the fork (its own macOS bundles), and host_supports_macos_minos() is the backstop. The pin only fired under an explicit --published-repo ggml-org override. - Windows Blackwell b9360: Windows-NVIDIA routes to the fork, whose windows-x64-cuda13 bundle covers Blackwell (manifest max_sm 120, toolkit 13.3), so the pin's self-disable check makes it dormant on every default install; it could only activate under the same upstream override on a 13.0-13.2 driver. Remove the pin constants, functions, and call sites. Keep the Blackwell capability detection (_drop_blackwell_incapable_windows_cuda, _host_is_blackwell, _windows_cuda_attempt_covers_blackwell) that still drops a non-sm_120 cuda-12.4 build on a Blackwell host. After this, an explicit --published-repo ggml-org override on a Blackwell 13.0-13.2 host loses its GPU fallback and lands on CPU; the default fork path is unaffected. Update the install selection-logic and macOS-compat unit tests for the new no-pin behavior. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: walk back deeper on the macOS upstream prebuilt path After removing the b9415 macOS pin, the explicit --published-repo ggml-org upstream path still used the default 2-release fallback, so a pre-macOS-26 host behind a run of macOS-26-only builds would exhaust two too-new plans (minos is only checked post-download) and drop to a source build before reaching a loadable older release. Walk back as deep as the fork macOS path (DEFAULT_MAX_MACOS_RELEASE_FALLBACKS), turning the removed static pin into dynamic discovery. Addresses review feedback on the macOS upstream fallback. * Studio: pin transformers during MLX self-heal so it cannot break Studio mlx-lm/mlx-vlm declare transformers>=5, but the single-env install pins transformers==4.57.6. The self-heal used --upgrade with no constraint, so it could upgrade transformers in the live venv and break the rest of Studio just to make import mlx.core pass. Pin transformers to the installed version via a constraint file: the resolver either finds an mlx build compatible with it or fails (we stay chat-only), never upgrading transformers underneath Studio. Addresses review feedback on the MLX repair install. * Studio: harden MLX self-heal against an unsupported mlx-vlm Pinning transformers alone made uv backtrack mlx-vlm to 0.3.9 (below unsloth-zoo's mlx-vlm>=0.4.4), which imports but breaks VLM Train/Export -- so the self-heal could clear chat-only onto a broken stack. Mirror the main installer: set UV_OVERRIDE=overrides-darwin-arm64.txt so a current mlx-vlm coexists with the transformers pin, require the same minimum versions unsloth-zoo declares, and gate/validate on a full mlx_stack_available() check (not a bare import) so an old or partial stack stays chat-only. Addresses PR review. * Studio: filter Blackwell-incapable CUDA in resolve_upstream_asset_choice resolve_upstream_asset_choice returned the first windows-cuda choice unfiltered, so a Blackwell host could be handed an sm_120-incapable cuda-12.4 build while the sibling planners drop it. Apply _drop_blackwell_incapable_windows_cuda here too and fall through to the CPU bundle on a Blackwell host with no capable GPU asset. Addresses PR review. * Studio: re-poll health so MLX self-heal reaches an open UI The sidebar cached the initial /api/health, so a successful background MLX self-heal (chat_only flips false) did not re-enable Train/Export until a manual reload. While chat-only for the recoverable mlx_unavailable reason, re-poll /api/health and stop once Train/Export become available. Addresses PR review. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make the disabled Train/Export tooltip reachable The greyed Train/Export items pass a tooltip explaining why (e.g. MLX missing), but a disabled + + + Local Model + + + Fine-tuned + + + Hugging Face + + +
{sourceMode === "checkpoint" ? (
+ +
- + - +
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-capabilities.ts b/studio/frontend/src/components/assistant-ui/model-selector/model-capabilities.ts new file mode 100644 index 0000000000..62ecac831b --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-capabilities.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Pure helpers that infer what a model can do (vision / reasoning / audio) from +// its HF tags + pipeline tag, falling back to repo-name keywords. No React/DOM +// deps so they stay easy to test. + +export interface ModelCapabilities { + vision: boolean; + reasoning: boolean; + audio: boolean; +} + +// Authoritative HF pipeline tags / tags for each capability. +const VISION_TAGS = new Set([ + "image-text-to-text", + "image-to-text", + "visual-question-answering", + "video-text-to-text", + "any-to-any", + "multimodal", + "vision", +]); +const AUDIO_TAGS = new Set([ + "automatic-speech-recognition", + "audio-text-to-text", + "text-to-speech", + "text-to-audio", + "audio-to-audio", + "audio-classification", +]); +const REASONING_TAGS = new Set(["reasoning"]); + +// Repo-name fallbacks, bounded so we never read a token out of a longer word. +const SEP = "(?:^|[-_/. ])"; +const END = "(?=$|[-_/. ])"; +const VISION_NAME_RE = new RegExp( + `${SEP}(?:vl|llava|pixtral|moondream|smolvlm|internvl|cogvlm|idefics|paligemma|vision)${END}`, + "i", +); +const REASONING_NAME_RE = new RegExp( + `${SEP}(?:r1|qwq|thinking|reason(?:ing|er)?|magistral|o1|marco)${END}`, + "i", +); +const AUDIO_NAME_RE = new RegExp( + `${SEP}(?:whisper|tts|parakeet|parler|musicgen|bark|orpheus|csm|voice|speech|audio)${END}`, + "i", +); + +function hasAny(tagSet: Set, wanted: Set): boolean { + for (const tag of wanted) if (tagSet.has(tag)) return true; + return false; +} + +/** Infer capabilities from HF tags + pipeline tag, then repo-name keywords. */ +export function detectCapabilities(opts: { + id: string; + tags?: readonly string[]; + pipelineTag?: string; +}): ModelCapabilities { + const { id, tags, pipelineTag } = opts; + const tagSet = new Set((tags ?? []).map((t) => t.toLowerCase())); + if (pipelineTag) tagSet.add(pipelineTag.toLowerCase()); + return { + vision: hasAny(tagSet, VISION_TAGS) || VISION_NAME_RE.test(id), + reasoning: hasAny(tagSet, REASONING_TAGS) || REASONING_NAME_RE.test(id), + audio: hasAny(tagSet, AUDIO_TAGS) || AUDIO_NAME_RE.test(id), + }; +} + +/** True when at least one capability is present (worth rendering a badge). */ +export function hasAnyCapability(caps: ModelCapabilities): boolean { + return caps.vision || caps.reasoning || caps.audio; +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx b/studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx new file mode 100644 index 0000000000..58510762d4 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; +import { cn } from "@/lib/utils"; +import { Settings02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +/** Gear button on a downloaded quant row. Stages the model into the Run + * settings sidebar (always, regardless of the Load-on-selection toggle) so the + * user can set load options, then click Load model. */ +export function ModelLoadSettingsAction({ + ariaLabel, + repoId, + quant, + maxContext, +}: { + ariaLabel: string; + repoId: string; + quant: string; + maxContext?: number | null; +}) { + return ( + + + + + + Configure run settings before loading model + + + ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts b/studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts new file mode 100644 index 0000000000..dbcd4b9a1b --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Tracks when each model was last loaded so the "Recent" sort can order by usage +// (distinct from "Downloaded", which orders by the file's download date). Kept in +// localStorage; ids are lowercased to match how the picker compares them. + +import { useEffect, useState } from "react"; + +export type ModelLoadTimes = Record; + +const STORAGE_KEY = "unsloth.model-load-times.v1"; + +function readLoadTimes(): ModelLoadTimes { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as ModelLoadTimes) : {}; + } catch { + return {}; + } +} + +/** Stamp a model as loaded now and return the updated map. */ +export function recordModelLoaded(id: string): ModelLoadTimes { + const next = { ...readLoadTimes(), [id.toLowerCase()]: Date.now() }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // ignore quota / disabled storage + } + return next; +} + +/** Epoch ms the model was last loaded, or -1 if never. */ +export function loadedAt(times: ModelLoadTimes, id: string): number { + return times[id.toLowerCase()] ?? -1; +} + +/** Load times, restamping whenever the active model changes. */ +export function useModelLoadTimes(currentValue?: string): ModelLoadTimes { + const [times, setTimes] = useState(() => readLoadTimes()); + useEffect(() => { + if (currentValue) setTimes(recordModelLoaded(currentValue)); + }, [currentValue]); + return times; +} 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 c08619d611..46b6fe2e63 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -9,6 +9,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { usePlatformStore } from "@/config/env"; +import { ApiProviderLogo } from "@/features/chat/api-provider-logo"; import { type ScanFolderInfo, addScanFolder, @@ -27,35 +28,83 @@ import type { CachedModelRepo, LocalModelInfo, } from "@/features/chat/api/chat-api"; +import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import type { GgufVariantDetail } from "@/features/chat/types/api"; +import { DotTag } from "@/features/hub/catalog/dot-tag"; import { - useDebouncedValue, - useGpuInfo, - useHfModelSearch, - useInfiniteScroll, - useRecommendedModelVram, -} from "@/hooks"; + type HubOption, + HubOptionMenu, +} from "@/features/hub/catalog/hub-option-menu"; +import { TrainIcon } from "@/features/hub/components/train-icon"; +import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll"; +import { + type HfModelResult, + type HfSortKey, + useHubModelSearch, +} from "@/features/hub/hooks/use-hub-model-search"; +import { useOnlineStatus } from "@/features/hub/hooks/use-online-status"; +import { isHiddenModelId } from "@/features/hub/lib/hidden-models"; +import { classifyUnslothSupport } from "@/features/hub/lib/unsloth-support"; +import { useHfTokenStore } from "@/features/hub/stores/hf-token-store"; +import { useDebouncedValue, useGpuInfo } from "@/hooks"; import { extractParamLabel } from "@/lib/model-size"; +import { toast } from "@/lib/toast"; import { cn, formatCompact } from "@/lib/utils"; import type { VramFitStatus } from "@/lib/vram"; import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; -import { Add01Icon, Cancel01Icon, Download01Icon, Folder02Icon, Search01Icon, StarIcon } from "@hugeicons/core-free-icons"; +import { + Add01Icon, + AudioWave01Icon, + Cancel01Icon, + DashboardCircleIcon, + Download01Icon, + Flag01Icon, + Folder02Icon, + RemoveCircleIcon, + Search01Icon, + ViewIcon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { FolderBrowser } from "./folder-browser"; -import { ModelDeleteAction } from "./model-delete-action"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { + type Dispatch, type KeyboardEvent, type ReactNode, + type SetStateAction, useCallback, useEffect, useId, useMemo, + useRef, useState, } from "react"; -import { toast } from "@/lib/toast"; +import { FolderBrowser } from "./folder-browser"; +import { + type ModelCapabilities, + detectCapabilities, + hasAnyCapability, +} from "./model-capabilities"; +import { ModelDeleteAction } from "./model-delete-action"; +import { ModelLoadSettingsAction } from "./model-load-settings-action"; +import { + type ModelLoadTimes, + loadedAt, + useModelLoadTimes, +} from "./model-usage"; +import { + type FormatFilter, + estimateQuantBytes, + fitsDevice, + isMlxId, + isMobileVariant, + isRecommendableFormat, + matchesFormatFilter, + paramsFromId, +} from "./recommended-fit"; +import { parseMetaTokens, splitRepoLabel } from "./row-meta"; import type { DeletedModelRef, + ExternalModelOption, LoraModelOption, ModelOption, ModelSelectorChangeMeta, @@ -65,17 +114,9 @@ function dedupe(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } -/** Newest-first by `last_modified` (epoch s), repo_id tie-break. Copies the - * input; treats a missing field as oldest for older-backend compatibility. */ -function sortByDownloadRecency( - rows: T[], -): T[] { - return [...rows].sort((a, b) => { - const at = a.last_modified ?? -1; - const bt = b.last_modified ?? -1; - if (at !== bt) return bt - at; - return a.repo_id.localeCompare(b.repo_id); - }); +/** Repos published by Unsloth; the rest group under the "Other models" section. */ +function isUnslothRepoId(repoId: string): boolean { + return repoId.toLowerCase().startsWith("unsloth/"); } /** Lowercase and strip separators for fuzzy search. */ @@ -92,7 +133,9 @@ function makeModelOptionChildrenId(optionKey: string): string { } function focusFirstChildOption(optionKey: string): boolean { - const childList = document.getElementById(makeModelOptionChildrenId(optionKey)); + const childList = document.getElementById( + makeModelOptionChildrenId(optionKey), + ); const option = childList?.querySelector( "[data-model-picker-option]", ); @@ -240,31 +283,49 @@ function useRovingModelList({ function ListLabel({ children, icon, + action, collapsed, onToggle, + divider, }: { children: ReactNode; icon?: ReactNode; + action?: ReactNode; collapsed?: boolean; onToggle?: () => void; + /** Draw a divider line above, evenly spaced, to separate it from the section + * above (omit on the first section). */ + divider?: boolean; }) { return ( -
+
{icon} {children} - {onToggle && ( - + {(action || onToggle) && ( +
+ {action} + {onToggle && ( + + )} +
)}
); @@ -291,6 +352,29 @@ function formatBytes(bytes: number): string { return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; } +// Small icon badges for what a model can do (vision / reasoning / audio). +// Vision and reasoning badges were dropped to keep rows uncluttered. +const CAPABILITY_BADGES = [ + { key: "audio" as const, icon: AudioWave01Icon, title: "Audio" }, +]; + +function CapabilityIcons({ caps }: { caps: ModelCapabilities }) { + return ( + <> + {CAPABILITY_BADGES.filter((b) => caps[b.key]).map((b) => ( + + + + ))} + + ); +} + function ModelRow({ label, meta, @@ -302,6 +386,11 @@ function ModelRow({ tooltipText, optionProps, onArrowDownIntoChildren, + capabilities, + hideOwner, + downloaded, + showVision, + className, }: { label: string; meta?: string | null; @@ -313,6 +402,15 @@ function ModelRow({ tooltipText?: ReactNode; optionProps?: ModelRowOptionProps; onArrowDownIntoChildren?: () => boolean; + /** Capability override (HF rows have tags); falls back to name detection. */ + capabilities?: ModelCapabilities; + /** Hide the "owner/" prefix (e.g. Recommended, where all are unsloth). */ + hideOwner?: boolean; + /** Mark a row already on disk (shown in Recommended instead of being hidden). */ + downloaded?: boolean; + /** Show a Vision badge on the name (On Device, read from GGUF metadata). */ + showVision?: boolean; + className?: string; }) { const exceeds = vramStatus === "exceeds"; const showVramTooltip = @@ -326,6 +424,14 @@ function ModelRow({ : `~${vramEst}GB VRAM` : null; + const { owner, name } = splitRepoLabel(label); + const parsed = parseMetaTokens(meta); + // Param chip from meta, else derived from the name so GGUF rows show it too. + const paramLabel = parsed.param ?? extractParamLabel(name) ?? null; + // Use the passed-in capabilities (tag-aware) or infer from the repo name. + const caps = capabilities ?? detectCapabilities({ id: label }); + const showCaps = hasAnyCapability(caps); + const content = ( ); @@ -412,6 +584,8 @@ function GgufVariantExpander({ renderDeleteVariantDescription, getDeleteVariantSuccessMessage, deleteDisabled = false, + onDevice = false, + onHasVision, }: { repoId: string; onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; @@ -426,10 +600,17 @@ function GgufVariantExpander({ renderDeleteVariantDescription?: (quant: string) => ReactNode; getDeleteVariantSuccessMessage?: (quant: string) => string; deleteDisabled?: boolean; + /** On Device rows honor the Show all quantizations setting; Recommended and + * other browse lists always show every quant. */ + onDevice?: boolean; + /** Report GGUF vision support up so the parent row can badge it. */ + onHasVision?: (hasVision: boolean) => void; }) { const [variants, setVariants] = useState(null); const [defaultVariant, setDefaultVariant] = useState(null); const [hasVision, setHasVision] = useState(false); + // Native max context (GGUF metadata); only set once a variant is downloaded. + const [nativeContext, setNativeContext] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -444,6 +625,8 @@ function GgufVariantExpander({ setVariants(res.variants); setDefaultVariant(res.default_variant); setHasVision(res.has_vision); + onHasVision?.(res.has_vision); + setNativeContext(res.context_length ?? null); }) .catch((err) => { if (canceled) return; @@ -467,15 +650,22 @@ function GgufVariantExpander({ const handleVariantClick = useCallback( (quant: string, downloaded?: boolean, sizeBytes?: number) => { + // Only seed the staged context for picks whose weights are already on + // disk. The staging effect short-circuits on a known contextLength + // (pendingHasContext) before starting the download, so attaching it to an + // undownloaded quant from a partially cached repo would skip the download + // entirely (and, with Load on selection, never load). + const isAvailable = isLocalPath || downloaded === true; onSelect(repoId, { source: sourceOverride ?? (isLocalPath ? "local" : "hub"), isLora: false, ggufVariant: quant, isDownloaded: isLocalPath ? true : downloaded, expectedBytes: sizeBytes, + contextLength: isAvailable ? nativeContext : undefined, }); }, - [repoId, isLocalPath, onSelect, sourceOverride], + [repoId, isLocalPath, onSelect, sourceOverride, nativeContext], ); // GGUF fit classification matching llama-server's _select_gpus logic: @@ -487,19 +677,24 @@ function GgufVariantExpander({ const getGgufFit = useCallback( (sizeBytes: number): "fits" | "tight" | "oom" => { - if (!gpuGb || gpuGb <= 0) return "fits"; + // No device budget at all (no GPU and no known system RAM): can't + // classify, so don't scare the user with OOM badges. + if (totalBudgetGb <= 0) return "fits"; const gb = sizeBytes / 1024 ** 3; if (gb <= 0 || gb <= gpuBudgetGb) return "fits"; + // No-GPU / unified-memory hosts (Mac) have only the RAM budget, so the + // tier collapses to fit-or-oom against system RAM rather than GPU+offload. + if (gpuBudgetGb <= 0) return gb <= totalBudgetGb ? "fits" : "oom"; if (gb <= totalBudgetGb) return "tight"; return "oom"; }, - [gpuGb, gpuBudgetGb, totalBudgetGb], + [gpuBudgetGb, totalBudgetGb], ); // If the recommended variant is OOM, pick the largest fitting one; // if all are OOM, recommend the smallest. const effectiveRecommended = useMemo(() => { - if (!variants || !gpuGb || gpuGb <= 0) return defaultVariant; + if (!variants || totalBudgetGb <= 0) return defaultVariant; const defaultV = variants.find((v) => v.quant === defaultVariant); if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom") return defaultVariant; @@ -512,7 +707,7 @@ function GgufVariantExpander({ // All OOM -- recommend smallest (most likely to partially run) const sorted = [...variants].sort((a, b) => a.size_bytes - b.size_bytes); return sorted[0].quant; - }, [variants, defaultVariant, gpuGb, getGgufFit]); + }, [variants, defaultVariant, totalBudgetGb, getGgufFit]); const sortedVariants = useMemo(() => { if (!variants) return variants; @@ -542,12 +737,24 @@ function GgufVariantExpander({ }); }, [variants, effectiveRecommended, getGgufFit]); + // On Device only: when Show all quantizations is off, list quants already on + // disk. Recommended and other browse lists always show every quant. + const showAllQuantizations = useChatRuntimeStore( + (s) => s.showAllQuantizations, + ); + const displayVariants = useMemo(() => { + if (!sortedVariants) return sortedVariants; + return showAllQuantizations || !onDevice + ? sortedVariants + : sortedVariants.filter((v) => v.downloaded); + }, [sortedVariants, showAllQuantizations, onDevice]); + const variantOptionKeys = useMemo( () => - (sortedVariants ?? []).map((variant) => + (displayVariants ?? []).map((variant) => makeModelOptionKey("gguf-variant", `${repoId}:${variant.filename}`), ), - [repoId, sortedVariants], + [repoId, displayVariants], ); const variantList = useRovingModelList({ label: `${repoId} quantizations`, @@ -569,7 +776,7 @@ function GgufVariantExpander({ return
{error}
; } - if (!sortedVariants || sortedVariants.length === 0) { + if (!displayVariants || displayVariants.length === 0) { return (
No GGUF variants found. @@ -587,15 +794,26 @@ function GgufVariantExpander({ } className="pl-4 border-l-2 border-accent/50 ml-3 my-1" > -
- - Quantizations - - {hasVision && ( - Vision - )} -
- {sortedVariants.map((v) => { + {/* On Device shows the model name above, so the Quantizations heading is + redundant; its Vision badge is relayed to the name instead. */} + {!onDevice && ( +
+ + Quantizations + + {hasVision && ( + + + Vision + + )} +
+ )} + {displayVariants.map((v) => { const fit = getGgufFit(v.size_bytes); const oom = fit === "oom"; const tight = fit === "tight"; @@ -610,11 +828,15 @@ function GgufVariantExpander({ handleVariantClick(v.quant, v.downloaded, v.size_bytes) } className={cn( - "flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-3 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[#3a3d44] dark:focus-visible:bg-[#3a3d44]", + "flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-2 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]", )} > - {v.quant} + + {v.quant} + {v.downloaded ? ( downloaded @@ -627,7 +849,7 @@ function GgufVariantExpander({ {oom && ( - + OOM )} @@ -641,6 +863,14 @@ function GgufVariantExpander({ + {v.downloaded && ( + + )} {v.downloaded && onDeleteVariant && ( 0 || + _cachedModelsCache.length > 0 || + _lmStudioCache.length > 0 || + _localDirCache.length > 0 || + _customFolderCache.length > 0 + ); +} + /** Sort LM Studio models with unsloth publisher first. */ function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] { return [...models].sort((a, b) => { @@ -711,22 +955,213 @@ function canDeleteLoraModel(model: LoraModelOption): boolean { // ── Hub Model Picker ────────────────────────────────────────── +// Recommended section sort. "recommended" = newly created unsloth GGUF/MLX that +// fit the device; the rest are plain HF sort keys over all unsloth models. +type RecommendedSortKey = "recommended" | "trendingScore" | "lastModified"; + +const RECOMMENDED_SORT_OPTIONS: HubOption[] = [ + { value: "recommended", label: "Recommended" }, + { value: "trendingScore", label: "Trending" }, + { value: "lastModified", label: "Recent" }, +]; + +// Sort for the On Device / Custom (local) lists. "recent" = last loaded; +// "downloaded" = file download date. +type LocalSortKey = "recent" | "downloaded" | "size" | "name"; + +const LOCAL_SORT_OPTIONS: HubOption[] = [ + { value: "recent", label: "Recent" }, + { value: "size", label: "Size" }, + { value: "name", label: "Name" }, + { value: "downloaded", label: "Downloaded" }, +]; + +// Format filter dropdown for the Unsloth listing. Plain labels are reused in +// the empty-state copy below. +const FORMAT_FILTER_LABELS: Record = { + all: "All", + gguf: "GGUF", + mlx: "MLX", + safetensors: "Safetensors", +}; + +// Dot colors match the row format tags: gguf blue, mlx amber, safetensors pink. +const FORMAT_FILTER_DOTS: Partial> = { + gguf: "bg-format-gguf", + mlx: "bg-format-mlx", + safetensors: "bg-format-checkpoint", +}; + +const FORMAT_FILTER_OPTIONS: HubOption[] = ( + Object.keys(FORMAT_FILTER_LABELS) as FormatFilter[] +).map((value) => { + const dot = FORMAT_FILTER_DOTS[value]; + return { + value, + label: dot ? ( + + + {FORMAT_FILTER_LABELS[value]} + + ) : ( + FORMAT_FILTER_LABELS[value] + ), + }; +}); + +/** Sort cached repos: by last-loaded, download date, size desc, or name. */ +function sortCachedRepos< + T extends { repo_id: string; size_bytes: number; last_modified?: number }, +>(rows: T[], key: LocalSortKey, loadTimes: ModelLoadTimes): T[] { + const byDate = (a: T, b: T) => + (b.last_modified ?? -1) - (a.last_modified ?? -1) || + a.repo_id.localeCompare(b.repo_id); + return [...rows].sort((a, b) => { + if (key === "name") return a.repo_id.localeCompare(b.repo_id); + if (key === "size") { + return b.size_bytes - a.size_bytes || a.repo_id.localeCompare(b.repo_id); + } + if (key === "recent") { + const d = loadedAt(loadTimes, b.repo_id) - loadedAt(loadTimes, a.repo_id); + return d !== 0 ? d : byDate(a, b); + } + return byDate(a, b); // "downloaded" + }); +} + +/** Sort local-provider models. They carry no size, so "size" falls back to name. */ +function sortLocalModels( + rows: LocalModelInfo[], + key: LocalSortKey, + loadTimes: ModelLoadTimes, +): LocalModelInfo[] { + const name = (m: LocalModelInfo) => m.model_id ?? m.display_name ?? m.id; + const byDate = (a: LocalModelInfo, b: LocalModelInfo) => + (b.updated_at ?? -1) - (a.updated_at ?? -1) || + name(a).localeCompare(name(b)); + return [...rows].sort((a, b) => { + if (key === "recent") { + const d = loadedAt(loadTimes, a.id) - loadedAt(loadTimes, b.id); + return d !== 0 ? -d : byDate(a, b); + } + if (key === "downloaded") return byDate(a, b); + return name(a).localeCompare(name(b)); // "size" (no size) and "name" + }); +} + +/** GGUF detection for a local model by backend format hint, name, or file path. */ +function localModelIsGguf(m: LocalModelInfo): boolean { + return ( + m.model_format === "gguf" || + isGgufRepo(m.id) || + isGgufRepo(m.display_name) || + m.path.toLowerCase().endsWith(".gguf") + ); +} + +/** Whether a local model is an MLX build (name hint). MLX runs on Mac only, so + * callers gate visibility on the host being a Mac. */ +function localModelIsMlx(m: LocalModelInfo): boolean { + return ( + isMlxId(m.id) || isMlxId(m.display_name) || isMlxId(m.model_id ?? "") + ); +} + +/** Whether a local model matches the format toggle (GGUF detected by name/path). */ +function localModelMatchesFormat( + m: LocalModelInfo, + filter: FormatFilter, +): boolean { + return matchesFormatFilter( + m.model_id ?? m.display_name ?? m.id, + localModelIsGguf(m), + filter, + ); +} + export function HubModelPicker({ models, + loraModels = [], + externalModels = [], value, onSelect, onFoldersChange, + onBrowseHub, + onModelsChange, + deleteDisabled = false, + section = "downloaded", + sectionToggle, + onEject, }: { models: ModelOption[]; + /** Fine-tuned models, shown as a section in the On Device view. */ + loraModels?: LoraModelOption[]; + /** Connected provider models, shown in the Connected section. */ + externalModels?: ExternalModelOption[]; value?: string; onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; onFoldersChange?: () => void; + /** Open the full Hub page to browse more models. */ + onBrowseHub?: () => void; + onModelsChange?: (deletedModel?: DeletedModelRef) => void; + deleteDisabled?: boolean; + /** Section shown when not searching. Search spans all sections. */ + section?: "downloaded" | "recommended" | "custom" | "connected"; + /** Section toggle rendered under the search bar. */ + sectionToggle?: ReactNode; + /** Eject the loaded model. Rendered as the last list row when set. */ + onEject?: () => void; }) { const gpu = useGpuInfo(); + // Last-loaded timestamps power the "Recent" sort (vs "Downloaded" = file date). + const loadTimes = useModelLoadTimes(value); + // Fade the list's top edge once scrolled, and its bottom edge while more + // rows sit below the fold. + const [listScrolled, setListScrolled] = useState(false); + const [listMoreBelow, setListMoreBelow] = useState(false); const [query, setQuery] = useState(""); const debouncedQuery = useDebouncedValue(query); - const { results, isLoading, isLoadingMore, fetchMore } = - useHfModelSearch(debouncedQuery); + // Shared Hub search stack (the same hooks the Hub page uses) so the picker + // and Hub run one implementation. Scoped to unsloth like the old listing. + const online = useOnlineStatus(); + const accessToken = useHfTokenStore((s) => s.token) || undefined; + // Recommended section: a live unsloth listing sorted by the dropdown. The + // same sort drives the search results so the dropdown works while searching. + const [recommendedSort, setRecommendedSort] = + useState("trendingScore"); + // "recommended" surfaces the most recently created Unsloth repos. + const recommendedSortBy: HfSortKey = + recommendedSort === "recommended" ? "createdAt" : recommendedSort; + const { + results, + isLoading, + isLoadingMore, + fetchMore, + scannedCount, + hasMore, + } = useHubModelSearch(debouncedQuery, { + ownerScope: "unsloth", + sortBy: recommendedSortBy, + sortDirection: "desc", + pinUnslothFirst: true, + keepUnsupportedTags: true, + accessToken, + // Only the Recommended section renders Hub results (On Device / Connected + // use local data), so keep the Hub hooks idle on the other tabs to avoid + // needless requests/spinner and to preserve offline-local behavior. + enabled: online && section === "recommended", + }); + const recommendedSearch = useHubModelSearch("", { + ownerScope: "unsloth", + sortBy: recommendedSortBy, + sortDirection: "desc", + pinUnslothFirst: true, + keepUnsupportedTags: true, + accessToken, + enabled: online && section === "recommended", + }); // Lowercased repo ids confirmed GGUF by the store or HF search. // Absence means "no hint" -> hasGgufSuffix is the fallback (don't @@ -739,13 +1174,15 @@ export function HubModelPicker({ } return ids; }, [models]); + // Both listings contribute GGUF hints so a tag-only GGUF (no "-GGUF" suffix) + // in Recommended still expands variants instead of loading as a checkpoint. const resultGgufIds = useMemo(() => { const ids = new Set(); - for (const result of results) { + for (const result of [...results, ...recommendedSearch.results]) { if (result.isGguf) ids.add(result.id.toLowerCase()); } return ids; - }, [results]); + }, [results, recommendedSearch.results]); const isKnownGgufRepo = useCallback( (id: string): boolean => { const key = id.toLowerCase(); @@ -756,10 +1193,97 @@ export function HubModelPicker({ // Track which GGUF repo is expanded for variant selection const [expandedGguf, setExpandedGguf] = useState(null); + // GGUF vision support per repo, reported by the expander once it has read the + // metadata, so On Device rows can show a Vision badge on the name. + const [visionByRepo, setVisionByRepo] = useState>({}); + const reportVision = useCallback((repoId: string, hasVision: boolean) => { + setVisionByRepo((prev) => + prev[repoId] === hasVision ? prev : { ...prev, [repoId]: hasVision }, + ); + }, []); + // When on, On Device GGUF repos show their quantizations without a click. + const expandQuantizations = useChatRuntimeStore((s) => s.expandQuantizations); + // Repos the user clicked to collapse while expand-by-default is on. Kept in + // memory only, so it resets on reload (and when the setting is toggled). + const [collapsedGguf, setCollapsedGguf] = useState>( + () => new Set(), + ); + useEffect(() => { + setCollapsedGguf(new Set()); + }, [expandQuantizations]); + const isGgufExpanded = useCallback( + (id: string) => + expandQuantizations ? !collapsedGguf.has(id) : expandedGguf === id, + [expandQuantizations, collapsedGguf, expandedGguf], + ); + // Toggle a repo's quantizations: flip the collapse set when expand-by-default + // is on, otherwise drive the single-open expandedGguf state. + const toggleGgufExpanded = useCallback( + (id: string) => { + if (expandQuantizations) { + setCollapsedGguf((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } else { + setExpandedGguf((prev) => (prev === id ? null : id)); + } + }, + [expandQuantizations], + ); const [downloadedCollapsed, setDownloadedCollapsed] = useState(false); + const [otherModelsCollapsed, setOtherModelsCollapsed] = useState(false); const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false); - const [recommendedCollapsed, setRecommendedCollapsed] = useState(false); + const [fineTunedCollapsed, setFineTunedCollapsed] = useState(false); + const [lmStudioCollapsed, setLmStudioCollapsed] = useState(false); + const [localDirCollapsed, setLocalDirCollapsed] = useState(false); + // The Fine-tuned section header; the train icon on the Unsloth header scrolls + // here so users can jump to their trained models. + const fineTunedSectionRef = useRef(null); + const scrollToFineTuned = useCallback(() => { + setFineTunedCollapsed(false); + // Two frames so the expand renders before we scroll the section to the top + // of the list. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + fineTunedSectionRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }); + }); + }, []); + // The Other models header; the directions icon on the Unsloth header scrolls + // here. + const otherModelsSectionRef = useRef(null); + const scrollToOtherModels = useCallback(() => { + setOtherModelsCollapsed(false); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + otherModelsSectionRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }); + }); + }, []); + // The Custom Folders header; the folder icon on the Unsloth header scrolls + // here instead of opening the browse popup. + const customFolderSectionRef = useRef(null); + const scrollToCustomFolders = useCallback(() => { + setCustomFoldersCollapsed(false); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + customFolderSectionRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }); + }); + }, []); // Cached (downloaded) repos -- module-level cache avoids flashing an // empty "Downloaded" section when the popover re-mounts. @@ -774,11 +1298,16 @@ export function HubModelPicker({ // LM Studio local models -- module-level cache, same pattern as above. const [lmStudioModels, setLmStudioModels] = useState(_lmStudioCache); + // Models found under the local models directory (./models), so they stay + // selectable on the On Device tab after leaving the Fine-tuned tab. + const [localDirModels, setLocalDirModels] = + useState(_localDirCache); const [customFolderModels, setCustomFolderModels] = useState(_customFolderCache); // Custom scan folders management - const [scanFolders, setScanFolders] = useState(_scanFoldersCache); + const [scanFolders, setScanFolders] = + useState(_scanFoldersCache); const [folderInput, setFolderInput] = useState(""); const [folderError, setFolderError] = useState(null); const [showFolderInput, setShowFolderInput] = useState(false); @@ -794,6 +1323,9 @@ export function HubModelPicker({ ); _lmStudioCache = lm; setLmStudioModels(lm); + const ld = res.models.filter((m) => m.source === "models_dir"); + _localDirCache = ld; + setLocalDirModels(ld); const cf = res.models.filter((m) => m.source === "custom"); _customFolderCache = cf; setCustomFolderModels(cf); @@ -810,58 +1342,72 @@ export function HubModelPicker({ .catch(() => {}); }, []); - const handleAddFolder = useCallback(async (overridePath?: string) => { - // Explicit path lets the folder browser submit in the same tick it - // calls `setFolderInput`; reading `folderInput` would race the update. - const raw = overridePath !== undefined ? overridePath : folderInput; - const trimmed = raw.trim(); - if (!trimmed || folderLoading) return; - setFolderError(null); - setFolderLoading(true); - // From the folder browser's one-click "Use this folder": the typed- - // input panel is closed, so the inline folderError is invisible. - // Surface failures (denylisted path, sandbox 403, etc.) via toast. - const fromBrowser = overridePath !== undefined; - try { - const created = await addScanFolder(trimmed); - // Backend returns the existing row for duplicates, so dedupe. - const next = _scanFoldersCache.some((f) => f.id === created.id || f.path === created.path) - ? _scanFoldersCache - : [..._scanFoldersCache, created]; - _scanFoldersCache = next; - setScanFolders(next); - setFolderInput(""); - setShowFolderInput(false); - refreshLocalModelsList(); - onFoldersChange?.(); - // Background reconciliation with the server - void refreshScanFolders(); - } catch (e) { - const message = e instanceof Error ? e.message : "Failed to add folder"; - setFolderError(message); - if (fromBrowser) { - toast.error("Couldn't add folder", { description: message }); + const handleAddFolder = useCallback( + async (overridePath?: string) => { + // Explicit path lets the folder browser submit in the same tick it + // calls `setFolderInput`; reading `folderInput` would race the update. + const raw = overridePath !== undefined ? overridePath : folderInput; + const trimmed = raw.trim(); + if (!trimmed || folderLoading) return; + setFolderError(null); + setFolderLoading(true); + // From the folder browser's one-click "Use this folder": the typed- + // input panel is closed, so the inline folderError is invisible. + // Surface failures (denylisted path, sandbox 403, etc.) via toast. + const fromBrowser = overridePath !== undefined; + try { + const created = await addScanFolder(trimmed); + // Backend returns the existing row for duplicates, so dedupe. + const next = _scanFoldersCache.some( + (f) => f.id === created.id || f.path === created.path, + ) + ? _scanFoldersCache + : [..._scanFoldersCache, created]; + _scanFoldersCache = next; + setScanFolders(next); + setFolderInput(""); + setShowFolderInput(false); + refreshLocalModelsList(); + onFoldersChange?.(); + // Background reconciliation with the server + void refreshScanFolders(); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to add folder"; + setFolderError(message); + if (fromBrowser) { + toast.error("Couldn't add folder", { description: message }); + } + } finally { + setFolderLoading(false); } - } finally { - setFolderLoading(false); - } - }, [folderInput, folderLoading, refreshScanFolders, refreshLocalModelsList, onFoldersChange]); + }, + [ + folderInput, + folderLoading, + refreshScanFolders, + refreshLocalModelsList, + onFoldersChange, + ], + ); - const handleRemoveFolder = useCallback(async (id: number) => { - try { - await removeScanFolder(id); - // Optimistic: drop it immediately. - const next = _scanFoldersCache.filter((f) => f.id !== id); - _scanFoldersCache = next; - setScanFolders(next); - refreshScanFolders(); - refreshLocalModelsList(); - onFoldersChange?.(); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Failed to remove folder"); - refreshScanFolders(); - } - }, [refreshScanFolders, refreshLocalModelsList, onFoldersChange]); + const handleRemoveFolder = useCallback( + async (id: number) => { + try { + await removeScanFolder(id); + // Optimistic: drop it immediately. + const next = _scanFoldersCache.filter((f) => f.id !== id); + _scanFoldersCache = next; + setScanFolders(next); + refreshScanFolders(); + refreshLocalModelsList(); + onFoldersChange?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove folder"); + refreshScanFolders(); + } + }, + [refreshScanFolders, refreshLocalModelsList, onFoldersChange], + ); const refreshCachedLists = useCallback(() => { listCachedGguf() @@ -921,11 +1467,33 @@ export function HubModelPicker({ }, [cachedGguf, cachedModels]); const chatOnly = usePlatformStore((s) => s.isChatOnly()); + const deviceType = usePlatformStore((s) => s.deviceType); + const isMac = deviceType === "mac"; + + // Drop models Studio can't run for chat (diffusion / image / video / etc.) + // using the Hub's classifier on the tags the listing already carries. + const isChatSupported = useCallback( + (r: HfModelResult) => + classifyUnslothSupport({ + modelId: r.id, + pipelineTag: r.pipelineTag, + tags: r.tags, + libraryName: r.libraryName, + quantMethod: r.quantMethod, + deviceType, + }).status !== "unsupported", + [deviceType], + ); const recommendedIds = useMemo(() => { const all = dedupe([...models.map((model) => model.id), value ?? ""]) + .filter((id) => !isHiddenModelId(id)) .filter((id) => !downloadedSet.has(id.toLowerCase())) - .filter((id) => !chatOnly || isKnownGgufRepo(id)) + // Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors + // on Mac (matches the empty Recommended view so search stays consistent). + .filter( + (id) => !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac), + ) .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id)); // Sort: GGUFs first, then hub models const gguf: string[] = []; @@ -935,121 +1503,378 @@ export function HubModelPicker({ else hub.push(id); } return [...gguf, ...hub]; - }, [models, value, downloadedSet, chatOnly, isKnownGgufRepo]); - - // Infinite scroll paging for the recommended section - const [recommendedPage, setRecommendedPage] = useState(1); - // Reset page when the underlying list changes - useEffect(() => { - setRecommendedPage(1); - }, [models, chatOnly]); - - const visibleRecommendedIds = useMemo(() => { - const hubStartIndex = recommendedIds.findIndex((id) => !isKnownGgufRepo(id)); - const allGguf = - hubStartIndex === -1 - ? recommendedIds - : recommendedIds.slice(0, hubStartIndex); - const allHub = - hubStartIndex === -1 ? [] : recommendedIds.slice(hubStartIndex); - // Interleave in chunks of 4: [4 gguf, 4 hub, 4 gguf, 4 hub, ...] - const result: string[] = []; - for (let p = 0; p < recommendedPage; p++) { - result.push(...allGguf.slice(p * 4, (p + 1) * 4)); - result.push(...allHub.slice(p * 4, (p + 1) * 4)); - } - return result; - }, [recommendedIds, recommendedPage, isKnownGgufRepo]); - - const hasMoreRecommended = - visibleRecommendedIds.length < recommendedIds.length; + }, [models, value, downloadedSet, chatOnly, isKnownGgufRepo, isMac]); const showHfSection = debouncedQuery.trim().length > 0; - // Newest-first (also covers older backends without `last_modified`). + // Independent sort for each local section's inline dropdown. + const [downloadedSort, setDownloadedSort] = useState("recent"); + const [customSort, setCustomSort] = useState("recent"); + // Format filter toggle for the Unsloth listing. + const [formatFilter, setFormatFilter] = useState("all"); + + // Recommended suggests GGUF anywhere; on Mac also MLX and safetensors. The + // "recommended" sort also drops models too big for the device. Already- + // downloaded models stay visible (badged), never hidden. + const recommendedRows = useMemo(() => { + // Never list mobile-targeted builds in the Unsloth section. + let rows = recommendedSearch.results + .filter((r) => !isHiddenModelId(r.id)) + .filter((r) => !isMobileVariant(r.id)); + // Drop models Studio can't run for chat (diffusion / image / video / etc.). + rows = rows.filter(isChatSupported); + // With no explicit format, show the device-recommended formats (GGUF, plus + // MLX on Mac). When the user picks a format, honor it instead so Safetensors + // is not dropped by the recommendation default. + rows = + formatFilter === "all" + ? rows.filter((r) => isRecommendableFormat(r.id, r.isGguf, isMac)) + : rows.filter((r) => matchesFormatFilter(r.id, r.isGguf, formatFilter)); + if (recommendedSort !== "recommended") return rows; + return rows.filter((r) => { + // Downloaded models always show, regardless of device fit. + if (downloadedSet.has(r.id.toLowerCase())) return true; + // Unified-memory hosts (Mac / no discrete GPU) still report system RAM, + // so fall back to that budget instead of skipping the fit check entirely. + const hasDeviceBudget = + gpu.memoryTotalGb > 0 || gpu.systemRamAvailableGb > 0; + if (!hasDeviceBudget) return true; + // GGUF/MLX repos rarely expose safetensors metadata, so fall back to the + // GGUF param count, then the repo name, for a size estimate. Anything we + // still cannot size is hidden (requireKnown) so over-budget models like a + // 1T GGUF don't slip into Recommended. + const params = r.totalParams ?? paramsFromId(r.id); + const sizeBytes = + r.estimatedSizeBytes ?? + (params ? estimateQuantBytes(params) : undefined); + return fitsDevice({ + sizeBytes, + gpuGb: gpu.memoryTotalGb, + systemRamGb: gpu.systemRamAvailableGb, + requireKnown: true, + }); + }); + }, [ + recommendedSearch.results, + downloadedSet, + recommendedSort, + formatFilter, + isMac, + gpu, + isChatSupported, + ]); + + // Per-row meta + VRAM badge from the recommended listing's own metadata. + const recommendedMeta = useMemo(() => { + const map = new Map< + string, + { meta: string | null; status: VramFitStatus | null; est: number } + >(); + for (const r of recommendedSearch.results) { + const isG = isKnownGgufRepo(r.id); + // GGUF param count comes from the repo name or the GGUF metadata, so even + // repos with no "B" token (Kimi, MiniMax) show a param chip. + const ggufParams = r.totalParams ?? paramsFromId(r.id); + const meta = isG + ? [ + ggufParams ? formatCompact(ggufParams) : null, + "GGUF", + r.estimatedSizeBytes ? formatBytes(r.estimatedSizeBytes) : null, + ] + .filter(Boolean) + .join(" · ") + : [ + r.totalParams + ? formatCompact(r.totalParams) + : extractParamLabel(r.id), + // MLX and safetensors get a format pill like GGUF. + isMlxId(r.id) ? "MLX" : "Safetensors", + r.estimatedSizeBytes ? formatBytes(r.estimatedSizeBytes) : null, + ] + .filter(Boolean) + .join(" · ") || null; + if (isG) { + // GGUF fit is size-based: flag OOM when even the smallest quant we can + // size exceeds the device budget. Repos we cannot size show no badge. + const params = ggufParams; + const sizeBytes = + r.estimatedSizeBytes ?? + (params ? estimateQuantBytes(params) : undefined); + const hasDeviceBudget = + gpu.memoryTotalGb > 0 || gpu.systemRamAvailableGb > 0; + const exceeds = + hasDeviceBudget && + sizeBytes != null && + !fitsDevice({ + sizeBytes, + gpuGb: gpu.memoryTotalGb, + systemRamGb: gpu.systemRamAvailableGb, + }); + map.set(r.id, { + meta, + status: exceeds ? "exceeds" : null, + est: sizeBytes ? Math.round(sizeBytes / 1024 ** 3) : 0, + }); + continue; + } + const est = r.totalParams + ? estimateLoadingVram(r.totalParams, "qlora") + : 0; + const status = + est > 0 && gpu.available ? checkVramFit(est, gpu.memoryTotalGb) : null; + map.set(r.id, { meta, status, est }); + } + return map; + }, [recommendedSearch.results, isKnownGgufRepo, gpu]); + + // Tag-accurate capabilities keyed by repo id, pooled from both HF listings. + // Rows look it up by id and fall back to name detection when absent. + const capsById = useMemo(() => { + const map = new Map(); + for (const r of [...results, ...recommendedSearch.results]) { + if (map.has(r.id)) continue; + map.set( + r.id, + detectCapabilities({ + id: r.id, + tags: r.tags, + pipelineTag: r.pipelineTag, + }), + ); + } + return map; + }, [results, recommendedSearch.results]); + + // Ordered by the On Device dropdown (recent/download date/size/name). const sortedCachedGguf = useMemo( - () => sortByDownloadRecency(cachedGguf), - [cachedGguf], + () => sortCachedRepos(cachedGguf, downloadedSort, loadTimes), + [cachedGguf, downloadedSort, loadTimes], ); const sortedCachedModels = useMemo( - () => sortByDownloadRecency(cachedModels), - [cachedModels], + () => sortCachedRepos(cachedModels, downloadedSort, loadTimes), + [cachedModels, downloadedSort, loadTimes], ); + // Each local section's search is scoped to its own models (matched by name). + const localQuery = normalizeForSearch(debouncedQuery.trim()); + const matchesLocalQuery = (m: LocalModelInfo) => + !localQuery || + normalizeForSearch( + `${m.model_id ?? ""} ${m.display_name} ${m.id}`, + ).includes(localQuery); + const sortedLmStudio = useMemo( + () => + sortLocalModels( + lmStudioModels.filter( + (m) => + localModelMatchesFormat(m, formatFilter) && matchesLocalQuery(m), + ), + downloadedSort, + loadTimes, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [lmStudioModels, downloadedSort, formatFilter, loadTimes, localQuery], + ); + // Local ./models entries. Chat-only Studio runs GGUF (any host) and MLX (Mac + // only), so raw checkpoints there are hidden (mirrors the cached non-GGUF + // rule). An MLX build a Mac user dropped in ./models stays selectable. + const sortedLocalDir = useMemo( + () => + sortLocalModels( + localDirModels.filter( + (m) => + (!chatOnly || + localModelIsGguf(m) || + (isMac && localModelIsMlx(m))) && + localModelMatchesFormat(m, formatFilter) && + matchesLocalQuery(m), + ), + downloadedSort, + loadTimes, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + localDirModels, + downloadedSort, + formatFilter, + isMac, + loadTimes, + localQuery, + chatOnly, + ], + ); + const sortedCustomFolderModels = useMemo( + () => + sortLocalModels( + customFolderModels.filter( + (m) => + localModelMatchesFormat(m, formatFilter) && matchesLocalQuery(m), + ), + customSort, + loadTimes, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [customFolderModels, customSort, formatFilter, loadTimes, localQuery], + ); + + // Fine-tuned models for the On Device "Fine-tuned" section: flat, query- + // filtered, newest first. + const fineTunedRows = useMemo(() => { + const needle = normalizeForSearch(debouncedQuery.trim()); + return loraModels + .filter((m) => { + const text = normalizeForSearch( + `${m.name} ${m.baseModel ?? ""} ${m.id}`, + ); + return !needle || text.includes(needle); + }) + .slice() + .sort((a, b) => { + const aTime = a.updatedAt ?? -1; + const bTime = b.updatedAt ?? -1; + if (aTime !== bTime) return bTime - aTime; + return a.name.localeCompare(b.name); + }); + }, [loraModels, debouncedQuery]); // While searching, filter Downloaded by the query instead of hiding it, so a // downloaded model the user is searching for stays visible. const visibleCachedGguf = useMemo(() => { - if (!showHfSection) return sortedCachedGguf; + if (!showHfSection) + return sortedCachedGguf.filter((c) => + matchesFormatFilter(c.repo_id, true, formatFilter), + ); const q = normalizeForSearch(debouncedQuery.trim()); - return sortedCachedGguf.filter((c) => normalizeForSearch(c.repo_id).includes(q)); - }, [sortedCachedGguf, showHfSection, debouncedQuery]); + // Keep the format filter active while searching so the dropdown stays + // consistent with the no-query branch (Safetensors selected shouldn't show + // GGUF downloads just because the user typed). + return sortedCachedGguf.filter( + (c) => + matchesFormatFilter(c.repo_id, true, formatFilter) && + normalizeForSearch(c.repo_id).includes(q), + ); + }, [sortedCachedGguf, showHfSection, debouncedQuery, formatFilter]); const visibleCachedModels = useMemo(() => { - if (!showHfSection) return sortedCachedModels; + if (!showHfSection) + return sortedCachedModels.filter((c) => + matchesFormatFilter(c.repo_id, false, formatFilter), + ); const q = normalizeForSearch(debouncedQuery.trim()); - return sortedCachedModels.filter((c) => normalizeForSearch(c.repo_id).includes(q)); - }, [sortedCachedModels, showHfSection, debouncedQuery]); + return sortedCachedModels.filter( + (c) => + matchesFormatFilter(c.repo_id, false, formatFilter) && + normalizeForSearch(c.repo_id).includes(q), + ); + }, [sortedCachedModels, showHfSection, debouncedQuery, formatFilter]); // Non-GGUF cached rows are not shown in chat-only mode, so the empty-state // logic must use this (not visibleCachedModels) or the picker can go blank. const visibleCachedModelRows = chatOnly ? [] : visibleCachedModels; + // Split downloaded models so non-Unsloth repos get their own "Other models" + // section above Fine-tuned. + const unslothCachedGguf = useMemo( + () => visibleCachedGguf.filter((c) => isUnslothRepoId(c.repo_id)), + [visibleCachedGguf], + ); + const otherCachedGguf = useMemo( + () => visibleCachedGguf.filter((c) => !isUnslothRepoId(c.repo_id)), + [visibleCachedGguf], + ); + const unslothCachedModelRows = useMemo( + () => visibleCachedModelRows.filter((c) => isUnslothRepoId(c.repo_id)), + [visibleCachedModelRows], + ); + const otherCachedModelRows = useMemo( + () => visibleCachedModelRows.filter((c) => !isUnslothRepoId(c.repo_id)), + [visibleCachedModelRows], + ); + // Recommended models that match the current search query const filteredRecommendedIds = useMemo(() => { if (!showHfSection) return []; const q = normalizeForSearch(debouncedQuery.trim()); - return recommendedIds.filter((id) => normalizeForSearch(id).includes(q)); - }, [showHfSection, debouncedQuery, recommendedIds]); + return recommendedIds + .filter((id) => normalizeForSearch(id).includes(q)) + .filter((id) => + matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter), + ); + }, [ + showHfSection, + debouncedQuery, + recommendedIds, + formatFilter, + isKnownGgufRepo, + ]); - // VRAM info for visible models plus any surfaced by a search query, so - // filtered recommended models also show VRAM badges. Skip GGUF repos: - // no safetensors metadata, and the render layer shows a "GGUF" badge. - const idsForVram = useMemo(() => { - const ids = showHfSection - ? [...new Set([...visibleRecommendedIds, ...filteredRecommendedIds])] - : visibleRecommendedIds; - return ids.filter((id) => !isKnownGgufRepo(id)); - }, [visibleRecommendedIds, showHfSection, filteredRecommendedIds, isKnownGgufRepo]); - const { paramCountById: recommendedParamCountById } = - useRecommendedModelVram(idsForVram); + // Param counts come straight off the unsloth listings the picker already + // loaded, so no extra per-id fetch is needed for the VRAM badges. + const recommendedParamCountById = useMemo(() => { + const map = new Map(); + for (const r of [...results, ...recommendedSearch.results]) { + if (r.totalParams) map.set(r.id, r.totalParams); + } + return map; + }, [results, recommendedSearch.results]); const recommendedSet = useMemo( - () => - new Set(showHfSection ? filteredRecommendedIds : visibleRecommendedIds), - [showHfSection, filteredRecommendedIds, visibleRecommendedIds], + () => new Set(filteredRecommendedIds), + [filteredRecommendedIds], ); const hfIds = useMemo(() => { - if (!showHfSection) return []; + // Only the Unsloth tab searches the HF listing, and only Unsloth models. + if (!showHfSection || section !== "recommended") return []; return results + .filter(isChatSupported) .map((result) => result.id) + .filter((id) => !isHiddenModelId(id)) + .filter((id) => id.toLowerCase().startsWith("unsloth/")) .filter((id) => !recommendedSet.has(id)) - // Shown under Downloaded (kept visible while searching); no duplicate. - .filter((id) => !downloadedSet.has(id.toLowerCase())) - .filter((id) => !chatOnly || isKnownGgufRepo(id)) - .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id)); - }, [recommendedSet, downloadedSet, results, showHfSection, chatOnly, isKnownGgufRepo]); + // Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors + // on Mac (matches the empty Recommended view so search stays consistent). + .filter( + (id) => !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac), + ) + .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id)) + .filter((id) => + matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter), + ); + }, [ + recommendedSet, + results, + showHfSection, + section, + chatOnly, + isKnownGgufRepo, + isChatSupported, + formatFilter, + isMac, + ]); const hubOptionKeys = useMemo(() => { const keys: string[] = []; + // Downloaded (Unsloth) rows (query-filtered) on the On Device tab only. if ( + section === "downloaded" && cachedReady && !downloadedCollapsed && - (visibleCachedGguf.length > 0 || visibleCachedModelRows.length > 0) + (unslothCachedGguf.length > 0 || unslothCachedModelRows.length > 0) ) { keys.push( - ...visibleCachedGguf.map((model) => + ...unslothCachedGguf.map((model) => makeModelOptionKey("downloaded-gguf", model.repo_id), ), ); keys.push( - ...visibleCachedModelRows.map((model) => + ...unslothCachedModelRows.map((model) => makeModelOptionKey("downloaded-model", model.repo_id), ), ); } - if (showHfSection) { + // Unsloth-tab search keys (curated matches + HF unsloth results). + if (showHfSection && section === "recommended") { keys.push( ...filteredRecommendedIds.map((id) => makeModelOptionKey("search-recommended", id), @@ -1059,45 +1884,84 @@ export function HubModelPicker({ return keys; } - if (chatOnly) { + // Other (non-Unsloth) downloaded rows sit just above Fine-tuned. + if ( + section === "downloaded" && + cachedReady && + !otherModelsCollapsed && + (otherCachedGguf.length > 0 || otherCachedModelRows.length > 0) + ) { keys.push( - ...lmStudioModels.map((model) => - makeModelOptionKey("lm-studio", model.id), + ...otherCachedGguf.map((model) => + makeModelOptionKey("downloaded-gguf", model.repo_id), + ), + ); + keys.push( + ...otherCachedModelRows.map((model) => + makeModelOptionKey("downloaded-model", model.repo_id), ), ); } - if (!customFoldersCollapsed) { + // Fine-tuned models sit below downloaded, above custom folders. + if (section === "downloaded" && !fineTunedCollapsed) { + keys.push(...fineTunedRows.map((m) => makeModelOptionKey("lora", m.id))); + } + + // Custom folders sit right below the downloaded models on On Device. + if (section === "downloaded" && !customFoldersCollapsed) { keys.push( - ...customFolderModels.map((model) => + ...sortedCustomFolderModels.map((model) => makeModelOptionKey("custom-folder", model.id), ), ); } - if (cachedReady && !recommendedCollapsed) { + if (section === "downloaded" && !lmStudioCollapsed) { keys.push( - ...visibleRecommendedIds.map((id) => - makeModelOptionKey("recommended", id), + ...sortedLmStudio.map((model) => + makeModelOptionKey("lm-studio", model.id), ), ); } + if (section === "downloaded" && !localDirCollapsed) { + keys.push( + ...sortedLocalDir.map((model) => + makeModelOptionKey("local-dir", model.id), + ), + ); + } + + if (section === "recommended") { + keys.push( + ...recommendedRows.map((r) => makeModelOptionKey("recommended", r.id)), + ); + } + return keys; }, [ cachedReady, chatOnly, - customFolderModels, + sortedCustomFolderModels, customFoldersCollapsed, downloadedCollapsed, + fineTunedRows, + fineTunedCollapsed, filteredRecommendedIds, hfIds, - lmStudioModels, - recommendedCollapsed, + sortedLmStudio, + lmStudioCollapsed, + recommendedRows, + section, showHfSection, - visibleCachedGguf, - visibleCachedModelRows, - visibleRecommendedIds, + sortedLocalDir, + localDirCollapsed, + unslothCachedGguf, + unslothCachedModelRows, + otherCachedGguf, + otherCachedModelRows, + otherModelsCollapsed, ]); const selectedHubOptionKey = useMemo( @@ -1153,9 +2017,10 @@ export function HubModelPicker({ string, { est: number; status: VramFitStatus | null; detail: string | null } >(); - const ids = showHfSection ? filteredRecommendedIds : visibleRecommendedIds; - for (const id of ids) { - const totalParams = recommendedParamCountById.get(id); + for (const id of filteredRecommendedIds) { + // GGUF fit is size-based and badged elsewhere; skip the qlora estimate. + if (isKnownGgufRepo(id)) continue; + const totalParams = recommendedParamCountById.get(id) ?? paramsFromId(id); if (totalParams) { const est = estimateLoadingVram(totalParams, "qlora"); const status = gpu.available @@ -1166,47 +2031,66 @@ export function HubModelPicker({ } } return map; - }, [ - showHfSection, - filteredRecommendedIds, - visibleRecommendedIds, - recommendedParamCountById, - gpu, - ]); + }, [filteredRecommendedIds, recommendedParamCountById, isKnownGgufRepo, gpu]); - const { scrollRef, sentinelRef } = useInfiniteScroll( + const { scrollRef, sentinelRef } = useHubInfiniteScroll( fetchMore, - results.length, + scannedCount, + { + enabled: online && hasMore, + isFetching: isLoading || isLoadingMore, + resultCount: results.length, + resetKey: debouncedQuery, + }, ); - // Sentinel + IntersectionObserver for recommended infinite scroll. - // Disconnect after each fire so it doesn't loop during re-render; the - // effect re-creates it next page. Callback ref detects mount/unmount. + // Recompute the top/bottom edge fades from the scroll position. + const updateListFades = useCallback((el: HTMLDivElement) => { + const scrolled = el.scrollTop > 0; + setListScrolled((prev) => (prev === scrolled ? prev : scrolled)); + const moreBelow = el.scrollHeight - el.scrollTop - el.clientHeight > 1; + setListMoreBelow((prev) => (prev === moreBelow ? prev : moreBelow)); + }, []); + + // Keep the fades in sync when rows are added, removed, or filtered. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + updateListFades(el); + const observer = new ResizeObserver(() => updateListFades(el)); + observer.observe(el); + if (el.firstElementChild) observer.observe(el.firstElementChild); + return () => observer.disconnect(); + }, [scrollRef, updateListFades]); + + // Sentinel + IntersectionObserver for recommended infinite scroll. Re-running + // on each loaded page (results length) re-attaches the observer so a heavily + // filtered list keeps paging until the viewport fills or the listing ends; + // fetchMore is a no-op while a page is in flight. Callback ref tracks mount. const [recommendedSentinel, setRecommendedSentinel] = useState(null); const recommendedSentinelRef = useCallback((node: HTMLDivElement | null) => { setRecommendedSentinel(node); }, []); useEffect(() => { - if (!recommendedSentinel || !hasMoreRecommended) return; + if (!recommendedSentinel || !recommendedSearch.hasMore) return; const root = scrollRef.current; if (!root) return; const obs = new IntersectionObserver( ([e]) => { - if (e.isIntersecting) { - obs.disconnect(); - setRecommendedPage((p) => p + 1); - } + if (e.isIntersecting) recommendedSearch.fetchMore(); }, { threshold: 0, root }, ); - // Small delay so layout settles after the previous page render. - const timer = setTimeout(() => obs.observe(recommendedSentinel), 100); - return () => { - clearTimeout(timer); - obs.disconnect(); - }; - }, [recommendedSentinel, hasMoreRecommended, recommendedPage, scrollRef]); + obs.observe(recommendedSentinel); + return () => obs.disconnect(); + }, [ + recommendedSentinel, + recommendedSearch.hasMore, + recommendedSearch.fetchMore, + recommendedSearch.results.length, + scrollRef, + ]); /** Handle clicking a model row — GGUF repos expand, others load directly. */ const handleModelClick = useCallback( @@ -1215,961 +2099,1468 @@ export function HubModelPicker({ // Toggle GGUF variant expander setExpandedGguf((prev) => (prev === id ? null : id)); } else { - onSelect(id, { source: "hub", isLora: false }); + // Cached repos load now; uncached ones download via the Hub manager. + onSelect(id, { + source: "hub", + isLora: false, + isDownloaded: downloadedSet.has(id.toLowerCase()), + }); } }, - [onSelect, isKnownGgufRepo], + [onSelect, isKnownGgufRepo, downloadedSet], ); + // On Device owns the downloaded and custom-folder models; the Unsloth tab + // searches the HF listing (below). Both filter locally by the query. + const showDownloaded = section === "downloaded"; + const showCustom = section === "downloaded"; + const showRecommendedSection = !showHfSection && section === "recommended"; + const downloadedEmpty = + visibleCachedGguf.length === 0 && + visibleCachedModelRows.length === 0 && + sortedLmStudio.length === 0 && + sortedLocalDir.length === 0 && + // Fine-tuned models are on-device too: don't show the empty state above a + // non-empty Fine-tuned section. + fineTunedRows.length === 0; + + // Sort dropdown shown inline to the right of the section toggle. Options + // depend on the tab and stay visible while searching so results can be + // sorted. Fixed width matching the Search Hub button so it and the format + // dropdown always line up; text-xs matches that button too. The trigger label + // clips (no ellipsis) when long; the open menu expands to show it in full. + const sortTriggerClassName = + "w-[110px] shrink-0 justify-between pr-2.5 !border-0 text-xs [&>span]:!text-clip"; + // Tighter menu like the Projects activity Select: less left/top padding and + // text-xs to match the trigger. Keep the option's right padding so the + // selected-item checkmark never overlaps the label. + const sortMenuContentClassName = + "!p-1 !rounded-[14px] [&_[role=option]]:!pl-2 [&_[role=option]]:!py-1.5 [&_[role=option]]:!text-xs [&_[role=option]]:!rounded-[10px]"; + const sectionSortDropdown = + section === "recommended" ? ( + + ) : section === "downloaded" ? ( + + ) : ( + + ); + + // Connected models grouped by provider, filtered by the shared search query. + const connectedGroups = useMemo(() => { + const needle = normalizeForSearch(debouncedQuery.trim()); + const byProvider = new Map< + string, + { + providerId: string; + providerName: string; + providerType: string; + models: ExternalModelOption[]; + } + >(); + for (const model of externalModels) { + const text = normalizeForSearch( + `${model.name} ${model.providerName} ${model.id}`, + ); + if (needle && !text.includes(needle)) continue; + const prev = byProvider.get(model.providerId); + if (prev) { + prev.models.push(model); + } else { + byProvider.set(model.providerId, { + providerId: model.providerId, + providerName: model.providerName, + providerType: model.providerType, + models: [model], + }); + } + } + return [...byProvider.values()] + .map((group) => ({ + ...group, + models: group.models.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); + }, [externalModels, debouncedQuery]); + const showConnected = section === "connected"; + // The Connected layout uses a wider box, so it drops the search inset to keep + // Search Hub on the last dropdown's edge while the right gap matches the left. + const hasConnected = externalModels.length > 0; + // The Other models section and its shortcut only show with non-Unsloth downloads. + const hasOtherModels = + otherCachedGguf.length > 0 || otherCachedModelRows.length > 0; + + const downloadedRowButtonClassName = + "bg-transparent pr-1 hover:bg-transparent focus-visible:bg-transparent dark:bg-transparent dark:hover:bg-transparent dark:focus-visible:bg-transparent"; + const downloadedRowShellClassName = (selected: boolean) => + cn( + "group flex items-center rounded-full transition-colors hover:bg-[#ececec] focus-within:bg-[#ececec] dark:hover:bg-[var(--sidebar-accent)] dark:focus-within:bg-[var(--sidebar-accent)]", + selected && "bg-[#ececec] dark:bg-[var(--sidebar-accent)]", + ); + + // Shared row renderers so Downloaded (Unsloth) and Other models render alike. + const renderDownloadedGgufRow = (c: (typeof visibleCachedGguf)[number]) => { + const optionKey = makeModelOptionKey("downloaded-gguf", c.repo_id); + const isSelected = value === c.repo_id; + return ( +
+
+
+ toggleGgufExpanded(c.repo_id)} + onArrowDownIntoChildren={ + isGgufExpanded(c.repo_id) + ? () => focusFirstChildOption(optionKey) + : undefined + } + vramStatus={null} + className={downloadedRowButtonClassName} + /> +
+
+ {isGgufExpanded(c.repo_id) && ( + reportVision(c.repo_id, v)} + onSelect={onSelect} + parentOptionKey={optionKey} + onNavigatePastStart={() => hubModelList.focusOption(optionKey)} + onNavigatePastEnd={() => hubModelList.moveFocus(optionKey, "next")} + gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} + systemRamGb={gpu.systemRamAvailableGb || undefined} + onDeleteVariant={async (quant) => { + await deleteCachedModel(c.repo_id, quant); + refreshCachedLists(); + }} + /> + )} +
+ ); + }; + const renderDownloadedModelRow = ( + c: (typeof visibleCachedModelRows)[number], + ) => { + const optionKey = makeModelOptionKey("downloaded-model", c.repo_id); + const isSelected = value === c.repo_id; + return ( +
+
+ + onSelect(c.repo_id, { + source: "hub", + isLora: false, + isDownloaded: true, + }) + } + vramStatus={null} + className={downloadedRowButtonClassName} + /> +
+ + This will remove{" "} + {c.repo_id}{" "} + from disk. You can re-download it later. + + } + successMessage={`Deleted ${c.repo_id}`} + buttonClassName="mr-1" + onConfirm={() => deleteCachedModel(c.repo_id)} + onDeleted={refreshCachedLists} + /> +
+ ); + }; + return ( -
-
- - setQuery(event.target.value)} - placeholder="Search models" - data-model-picker-search-input={true} - className="h-9 border-[#f2f2f2] dark:border-input pl-8 pr-8" - /> - {isLoading && ( - +
+ {/* A small right inset shortens the search bar so Search Hub lands on the + last dropdown's right edge (none on the wider Connected box). */} +
+
+ + setQuery(event.target.value)} + placeholder="Search models" + data-model-picker-search-input={true} + className="field-soft h-9 border-0 pl-8 pr-8" + /> + {isLoading && ( + + )} +
+ {onBrowseHub ? ( + + ) : null} +
+ + {/* Section tabs then the format and sort dropdowns, packed left with one + uniform gap between every control. The box is sized so the last + dropdown still lands on Search Hub's edge. Dropdowns hide on Connected. */} +
+ {sectionToggle} + {showConnected ? null : ( +
+ + {sectionSortDropdown} +
)}
updateListFades(e.currentTarget)} + className={cn( + // List sits within the menu padding so left and right gaps match. + // 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", + listScrolled && "is-scrolled", + listMoreBelow && "is-bottom-faded", + )} {...hubModelList.listboxProps} > -
- {/* First-load spinner only when nothing cached is shown yet. */} - {!cachedReady && - !showHfSection && - visibleCachedGguf.length === 0 && - visibleCachedModelRows.length === 0 ? ( -
- - - Loading models… - -
- ) : null} - - {/* Downloaded stays visible (filtered) while searching. */} - {visibleCachedGguf.length > 0 || visibleCachedModelRows.length > 0 ? ( - <> - } - collapsed={downloadedCollapsed} - onToggle={() => setDownloadedCollapsed((v) => !v)} - >Downloaded - {!downloadedCollapsed && - visibleCachedGguf.map((c) => { - const optionKey = makeModelOptionKey("downloaded-gguf", c.repo_id); - return ( -
- - setExpandedGguf((prev) => - prev === c.repo_id ? null : c.repo_id, - ) - } - onArrowDownIntoChildren={ - expandedGguf === c.repo_id - ? () => { - const focused = focusFirstChildOption(optionKey); - return focused; - } - : undefined - } - vramStatus={null} - /> - {expandedGguf === c.repo_id && ( - - hubModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - hubModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - onDeleteVariant={async (quant) => { - await deleteCachedModel(c.repo_id, quant); - refreshCachedLists(); - }} - /> - )} -
- ); - })} - {!downloadedCollapsed && - visibleCachedModelRows.map((c) => { - const optionKey = makeModelOptionKey("downloaded-model", c.repo_id); - return ( -
-
- - onSelect(c.repo_id, { - source: "hub", - isLora: false, - isDownloaded: true, - }) - } - vramStatus={null} - /> -
- - This will remove{" "} - - {c.repo_id} - {" "} - from disk. You can re-download it later. - - } - successMessage={`Deleted ${c.repo_id}`} - onConfirm={() => deleteCachedModel(c.repo_id)} - onDeleted={refreshCachedLists} - /> -
- ); - })} - - ) : null} - - {!showHfSection && chatOnly && lmStudioModels.length > 0 ? ( - <> - LM Studio - {lmStudioModels.map((m) => { - const isGgufFile = m.path.toLowerCase().endsWith(".gguf"); - const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name); - const optionKey = makeModelOptionKey("lm-studio", m.id); - return ( -
- { - if (isGguf) { - setExpandedGguf((prev) => - prev === m.id ? null : m.id, - ); - } else { - onSelect(m.id, { - source: "local", - isLora: false, - isDownloaded: true, - isGguf: isGgufFile, - }); - } - }} - onArrowDownIntoChildren={ - expandedGguf === m.id - ? () => { - const focused = focusFirstChildOption(optionKey); - return focused; - } - : undefined - } - vramStatus={null} - /> - {expandedGguf === m.id && ( - - hubModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - hubModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - /> - )} -
- ); - })} - - ) : null} - - {!showHfSection ? ( - <> -
- - - Custom Folders - -
- - -
-
- -
+ {/* Clear space for the floating Eject pill when scrolled to the end, so + its gap above the last row matches its gap below (applies to every + section, including Recommended). */} +
+ {showConnected ? ( + connectedGroups.length === 0 ? ( +
+ {externalModels.length === 0 + ? "No models from your connections. Set up in Settings then Connections." + : "No models match your search."}
- - {/* Folder paths */} - {!customFoldersCollapsed && scanFolders.map((f) => ( -
- - - {f.path} - - -
- ))} - - {/* Recommended folders */} - {!customFoldersCollapsed && (() => { - const registered = new Set(scanFolders.map((f) => f.path)); - const unregistered = recommendedFolders.filter((p) => !registered.has(p)); - if (unregistered.length === 0) return null; - return ( -
- {unregistered.map((p) => ( - - ))} -
- ); - })()} - - {/* Add folder input */} - {!customFoldersCollapsed && showFolderInput && ( -
-
- - { setFolderInput(e.target.value); setFolderError(null); }} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); handleAddFolder(); } - if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setShowFolderInput(false); setFolderInput(""); setFolderError(null); } - }} - placeholder="/path/to/models" - className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20" - disabled={folderLoading} - autoFocus={true} + ) : ( + connectedGroups.map((group) => ( +
+
+ + + {group.providerName} + +
+ {group.models.map((model) => ( + + ))} +
+ )) + ) + ) : ( + <> + {/* First-load spinner only when nothing cached is shown yet. */} + {showDownloaded && + !cachedReady && + !showHfSection && + downloadedEmpty ? ( +
+ + + Loading models… + +
+ ) : null} + + {/* Empty On Device: a search miss vs nothing downloaded yet. Hidden + when custom folders below still have matches. */} + {showDownloaded && + cachedReady && + downloadedEmpty && + sortedCustomFolderModels.length === 0 ? ( +
+ {showHfSection + ? "No matching models on device." + : formatFilter === "all" + ? "No downloaded models yet. Search above or pick Recommended." + : `No downloaded ${FORMAT_FILTER_LABELS[formatFilter]} models yet.`} +
+ ) : null} + + {/* Downloaded (Unsloth) stays visible (filtered) while searching. */} + {showDownloaded && + (unslothCachedGguf.length > 0 || + unslothCachedModelRows.length > 0) ? ( + <> + setDownloadedCollapsed((v) => !v)} + action={ + <> + {hasOtherModels ? ( + + + + + + Other non-Unsloth models + + + ) : null} + + + + + + Go to fine-tuned models + + + + + + + + Go to custom folders + + + + } + > + {/* When other providers (LM Studio/Ollama) also show here, name + this group "Unsloth" so the two are easy to tell apart. */} + {sortedLmStudio.length > 0 ? "Unsloth" : "Downloaded"} + + {!downloadedCollapsed && + unslothCachedGguf.map(renderDownloadedGgufRow)} + {!downloadedCollapsed && + unslothCachedModelRows.map(renderDownloadedModelRow)} + + ) : null} + + {/* Other models: non-Unsloth downloads, grouped just above + Fine-tuned. Shown only when such models exist. */} + {showDownloaded && hasOtherModels ? ( +
+ + } + collapsed={otherModelsCollapsed} + onToggle={() => setOtherModelsCollapsed((v) => !v)} + > + Other models + + {!otherModelsCollapsed && + otherCachedGguf.map(renderDownloadedGgufRow)} + {!otherModelsCollapsed && + otherCachedModelRows.map(renderDownloadedModelRow)} +
+ ) : null} + + {/* Fine-tuned models: a section above Custom Folders. Always shown on + On Device so the train shortcut always has a target, with an empty + state when none exist. */} + {section === "downloaded" ? ( + <> +
+ + + Fine-tuned + +
+ +
+
+ {!fineTunedCollapsed && fineTunedRows.length > 0 && ( + + )} + + ) : null} + + {showCustom ? ( + <> +
- +
+ + +
+
+ +
- {folderError && ( -

{folderError}

+ + {/* Folder paths */} + {!customFoldersCollapsed && + scanFolders.map((f) => ( +
+ + + {f.path} + + +
+ ))} + + {/* Recommended folders */} + {!customFoldersCollapsed && + (() => { + const registered = new Set( + scanFolders.map((f) => f.path), + ); + const unregistered = recommendedFolders.filter( + (p) => !registered.has(p), + ); + if (unregistered.length === 0) return null; + return ( +
+ {unregistered.map((p) => ( + + ))} +
+ ); + })()} + + {/* Add folder input */} + {!customFoldersCollapsed && showFolderInput && ( +
+
+ + { + setFolderInput(e.target.value); + setFolderError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddFolder(); + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setShowFolderInput(false); + setFolderInput(""); + setFolderError(null); + } + }} + placeholder="/path/to/models" + className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20" + disabled={folderLoading} + autoFocus={true} + /> + + +
+ {folderError && ( +

+ {folderError} +

+ )} +
)} -
- )} - { - setFolderInput(picked); - setFolderError(null); - // Pass the path explicitly: `folderInput` state hasn't - // flushed yet when "Use this folder" submits. - void handleAddFolder(picked); - }} - /> + { + setFolderInput(picked); + setFolderError(null); + // Pass the path explicitly: `folderInput` state hasn't + // flushed yet when "Use this folder" submits. + void handleAddFolder(picked); + }} + /> - - {/* Models from custom folders */} - {!customFoldersCollapsed && customFolderModels.map((m) => { - const isGgufFile = m.path.toLowerCase().endsWith(".gguf"); - const isGguf = - isGgufFile || - isGgufRepo(m.id) || - isGgufRepo(m.display_name); - // Single .gguf files (e.g. Ollama blobs) load directly; - // GGUF repos/directories expand to pick a variant. - const isDirectGguf = isGgufFile; - const optionKey = makeModelOptionKey("custom-folder", m.id); - return ( -
- { - if (isDirectGguf) { - onSelect(m.id, { - source: "local", - isLora: false, - isDownloaded: true, - isGguf: true, - }); - } else if (isGguf) { - setExpandedGguf((prev) => - prev === m.id ? null : m.id, - ); - } else { - onSelect(m.id, { - source: "local", - isLora: false, - isDownloaded: true, - }); - } - }} - onArrowDownIntoChildren={ - expandedGguf === m.id - ? () => { - const focused = focusFirstChildOption(optionKey); - return focused; - } - : undefined - } - vramStatus={null} - /> - {expandedGguf === m.id && ( - - hubModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - hubModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - /> - )} -
- ); - })} - - ) : null} - - {!showHfSection && cachedReady ? ( - <> - } - collapsed={recommendedCollapsed} - onToggle={() => setRecommendedCollapsed((v) => !v)} - >Recommended - {recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? ( -
- No default models. -
- ) : ( - visibleRecommendedIds.map((id) => { - const vram = recommendedVramMap.get(id); - const optionKey = makeModelOptionKey("recommended", id); - return ( -
- { - if (isKnownGgufRepo(id)) { - setExpandedGguf((prev) => (prev === id ? null : id)); - } else { - handleModelClick(id); - } - }} - vramStatus={ - isKnownGgufRepo(id) ? null : (vram?.status ?? null) - } - vramEst={isKnownGgufRepo(id) ? undefined : vram?.est} - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - onArrowDownIntoChildren={ - expandedGguf === id - ? () => { - const focused = focusFirstChildOption(optionKey); - return focused; - } - : undefined - } - /> - {expandedGguf === id && ( - - hubModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - hubModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - /> - )} -
- ); - }) - )} - {!recommendedCollapsed && hasMoreRecommended && ( - <> -
-
- -
- - )} - - ) : null} - - {showHfSection && filteredRecommendedIds.length > 0 ? ( - <> - }>Recommended - {filteredRecommendedIds.map((id) => { - const vram = recommendedVramMap.get(id); - const optionKey = makeModelOptionKey("search-recommended", id); - return ( -
- { - if (isKnownGgufRepo(id)) { - setExpandedGguf((prev) => (prev === id ? null : id)); - } else { - handleModelClick(id); - } - }} - vramStatus={ - isKnownGgufRepo(id) ? null : (vram?.status ?? null) - } - vramEst={isKnownGgufRepo(id) ? undefined : vram?.est} - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - onArrowDownIntoChildren={ - expandedGguf === id - ? () => { - const focused = focusFirstChildOption(optionKey); - return focused; - } - : undefined - } - /> - {expandedGguf === id && ( - - hubModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - hubModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - /> - )} -
- ); - })} - - ) : null} - - {showHfSection ? ( - <> - {(hfIds.length > 0 || isLoading) && ( - Hugging Face - )} - {hfIds.length === 0 && !isLoading ? ( - filteredRecommendedIds.length === 0 && - visibleCachedGguf.length === 0 && - visibleCachedModelRows.length === 0 ? ( -
- No matching models. -
- ) : null - ) : ( - hfIds.map((id) => { - const vram = vramMap.get(id); - const isSearchGguf = isKnownGgufRepo(id); - const optionKey = makeModelOptionKey("search-hf", id); - return ( -
- { - if (isSearchGguf) { - setExpandedGguf((prev) => (prev === id ? null : id)); - } else { - handleModelClick(id); - } - }} - vramStatus={ - isSearchGguf ? null : (vram?.status ?? null) - } - vramEst={isSearchGguf ? undefined : vram?.est} - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - onArrowDownIntoChildren={ - expandedGguf === id - ? () => { - const focused = focusFirstChildOption(optionKey); - return focused; - } - : undefined - } - /> - {expandedGguf === id && ( - - hubModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - hubModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - /> - )} -
- ); - }) - )} -
- {isLoadingMore ? ( -
- -
- ) : null} - - ) : null} -
-
- -
- ); -} - -export function LoraModelPicker({ - loraModels, - value, - onSelect, - onModelsChange, - deleteDisabled = false, -}: { - loraModels: LoraModelOption[]; - value?: string; - onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; - onModelsChange?: (deletedModel?: DeletedModelRef) => void; - deleteDisabled?: boolean; -}) { - const [query, setQuery] = useState(""); - const [expandedGguf, setExpandedGguf] = useState(null); - const gpu = useGpuInfo(); - - const normalized = useMemo( - () => - loraModels - .map((model) => ({ - ...model, - baseModel: - model.baseModel || model.description || "Unknown base model", - })) - .sort((a, b) => { - const baseCmp = a.baseModel.localeCompare(b.baseModel); - if (baseCmp !== 0) return baseCmp; - // Prioritize unsloth publisher within LM Studio group - if (a.baseModel === "LM Studio" && b.baseModel === "LM Studio") { - const aUnsloth = a.name.startsWith("unsloth/") ? 0 : 1; - const bUnsloth = b.name.startsWith("unsloth/") ? 0 : 1; - if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth; - } - const aTime = a.updatedAt ?? -1; - const bTime = b.updatedAt ?? -1; - if (aTime !== bTime) return bTime - aTime; - return a.name.localeCompare(b.name); - }), - [loraModels], - ); - - const grouped = useMemo(() => { - const needle = normalizeForSearch(query.trim()); - const out = new Map(); - - for (const model of normalized) { - const searchText = normalizeForSearch( - `${model.name} ${model.baseModel} ${model.id}`, - ); - if (needle && !searchText.includes(needle)) continue; - - const key = model.baseModel || "Unknown base model"; - const prev = out.get(key) ?? []; - prev.push(model); - out.set(key, prev); - } - - return [...out.entries()].sort((a, b) => { - const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1)); - const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1)); - if (aLatest !== bLatest) return bLatest - aLatest; - return a[0].localeCompare(b[0]); - }); - }, [normalized, query]); - - const loraOptionKeys = useMemo( - () => - grouped.flatMap(([, adapters]) => - adapters.map((adapter) => makeModelOptionKey("lora", adapter.id)), - ), - [grouped], - ); - const selectedLoraOptionKey = useMemo( - () => - value - ? loraOptionKeys.find((optionKey) => optionKey.endsWith(`::${value}`)) - : undefined, - [loraOptionKeys, value], - ); - const loraModelList = useRovingModelList({ - label: "Fine-tuned models", - optionKeys: loraOptionKeys, - selectedOptionKey: selectedLoraOptionKey, - }); - - return ( -
-
- - setQuery(event.target.value)} - placeholder="Search trained models" - data-model-picker-search-input={true} - className="h-9 border-[#f2f2f2] dark:border-input pl-8" - /> -
- -
-
- {grouped.length === 0 ? ( -
- No trained models found. -
- ) : ( - grouped.map(([baseModel, adapters], index) => ( -
- {index > 0 ?
: null} - {baseModel} - {adapters.map((adapter) => { - const isLocal = adapter.source === "local"; - const isTraining = adapter.source === "training"; - const isExported = adapter.source === "exported"; - const isMerged = adapter.exportType === "merged"; - const isGguf = adapter.exportType === "gguf"; - const isExportedGguf = isExported && isGguf; - const canDelete = canDeleteLoraModel(adapter); - const isTrainingFull = isTraining && isMerged; - const isLocalGgufDir = - isLocal && - (isGgufRepo(adapter.id) || isGgufRepo(adapter.name)); - const optionKey = makeModelOptionKey("lora", adapter.id); - const tag = isLocal - ? isLocalGgufDir - ? "GGUF" - : "Local" - : isGguf - ? "GGUF" - : isTrainingFull - ? "Full" - : isExported - ? isMerged - ? "Merged" - : "LoRA" - : "LoRA"; - const meta = isLocal - ? isLocalGgufDir - ? "GGUF" - : "Local" - : isTrainingFull - ? "Full finetune" - : isExported - ? `${tag} · Exported` - : tag; - return ( -
-
-
+ {/* Models from custom folders */} + {!customFoldersCollapsed && + sortedCustomFolderModels.map((m) => { + const isGgufFile = m.path.toLowerCase().endsWith(".gguf"); + // Honor the backend model_format hint (suffixless GGUF + // folders) in addition to name/path so the row classifies + // and loads through the same GGUF path as the filter. + const isGguf = localModelIsGguf(m); + // Single .gguf files (e.g. Ollama blobs) load directly; + // GGUF repos/directories expand to pick a variant. + const isDirectGguf = isGgufFile; + const optionKey = makeModelOptionKey( + "custom-folder", + m.id, + ); + return ( +
{ - if (isLocalGgufDir || isExportedGguf) { - setExpandedGguf((prev) => - prev === adapter.id ? null : adapter.id, - ); + if (isDirectGguf) { + onSelect(m.id, { + source: "local", + isLora: false, + isDownloaded: true, + // Mark GGUF so "Load on selection = off" stages + // through Run settings (matches LM Studio path). + isGguf: true, + }); + } else if (isGguf) { + toggleGgufExpanded(m.id); } else { - onSelect(adapter.id, { - source: isLocal - ? "local" - : isExported - ? "exported" - : "lora", - isLora: !isLocal && !isMerged && !isGguf, + onSelect(m.id, { + source: "local", + isLora: false, isDownloaded: true, }); } }} - tooltipText={ - <> - - {adapter.name} - - - {adapter.id} - - + onArrowDownIntoChildren={ + isGgufExpanded(m.id) + ? () => { + const focused = + focusFirstChildOption(optionKey); + return focused; + } + : undefined + } + vramStatus={null} + /> + {isGgufExpanded(m.id) && ( + + hubModelList.focusOption(optionKey) + } + onNavigatePastEnd={() => + hubModelList.moveFocus(optionKey, "next") + } + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + systemRamGb={gpu.systemRamAvailableGb || undefined} + /> + )} +
+ ); + })} + {!customFoldersCollapsed && + showHfSection && + sortedCustomFolderModels.length === 0 ? ( +
+ No matching models in custom folders. +
+ ) : null} + + ) : null} + + {section === "downloaded" && sortedLmStudio.length > 0 ? ( + <> + setLmStudioCollapsed((v) => !v)} + > + LM Studio + + {!lmStudioCollapsed && + sortedLmStudio.map((m) => { + const isGgufFile = m.path.toLowerCase().endsWith(".gguf"); + // LM Studio dirs are GGUF but rarely carry a -GGUF suffix; + // use the shared helper (model_format hint) so the row, + // filter, and load path agree. + const isGguf = localModelIsGguf(m); + const optionKey = makeModelOptionKey("lm-studio", m.id); + return ( +
+ { + if (isGgufFile) { + onSelect(m.id, { + source: "local", + isLora: false, + isDownloaded: true, + isGguf: true, + }); + } else if (isGguf) { + toggleGgufExpanded(m.id); + } else { + onSelect(m.id, { + source: "local", + isLora: false, + isDownloaded: true, + }); + } + }} + onArrowDownIntoChildren={ + !isGgufFile && isGgufExpanded(m.id) + ? () => { + const focused = + focusFirstChildOption(optionKey); + return focused; + } + : undefined + } + vramStatus={null} + /> + {!isGgufFile && isGgufExpanded(m.id) && ( + + hubModelList.focusOption(optionKey) + } + onNavigatePastEnd={() => + hubModelList.moveFocus(optionKey, "next") + } + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + systemRamGb={gpu.systemRamAvailableGb || undefined} + /> + )} +
+ ); + })} + + ) : null} + + {section === "downloaded" && sortedLocalDir.length > 0 ? ( + <> + setLocalDirCollapsed((v) => !v)} + > + Local models + + {!localDirCollapsed && + sortedLocalDir.map((m) => { + // A loose ./models/*.gguf file loads directly; a GGUF repo + // directory expands to pick a variant. The backend's local + // variant scanner returns nothing for a config-less loose + // file, so expanding it would dead-end at "No GGUF variants". + const isGgufFile = m.path.toLowerCase().endsWith(".gguf"); + const isGguf = localModelIsGguf(m); + const optionKey = makeModelOptionKey("local-dir", m.id); + return ( +
+ { + if (isGgufFile) { + onSelect(m.id, { + source: "local", + isLora: false, + isDownloaded: true, + isGguf: true, + }); + } else if (isGguf) { + toggleGgufExpanded(m.id); + } else { + onSelect(m.id, { + source: "local", + isLora: false, + isDownloaded: true, + }); + } + }} + onArrowDownIntoChildren={ + !isGgufFile && isGgufExpanded(m.id) + ? () => focusFirstChildOption(optionKey) + : undefined + } + vramStatus={null} + /> + {!isGgufFile && isGgufExpanded(m.id) && ( + + hubModelList.focusOption(optionKey) + } + onNavigatePastEnd={() => + hubModelList.moveFocus(optionKey, "next") + } + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + systemRamGb={gpu.systemRamAvailableGb || undefined} + /> + )} +
+ ); + })} + + ) : null} + + {showRecommendedSection ? ( + <> + {recommendedSearch.isLoading && + recommendedRows.length === 0 ? ( +
+ + + Loading models… + +
+ ) : recommendedRows.length === 0 ? ( +
+ No models found. +
+ ) : ( + recommendedRows.map((r) => { + const id = r.id; + const info = recommendedMeta.get(id); + const isG = isKnownGgufRepo(id); + const optionKey = makeModelOptionKey("recommended", id); + return ( +
+ { + if (isG) { + setExpandedGguf((prev) => + prev === id ? null : id, + ); + } else { + handleModelClick(id); + } + }} + vramStatus={info?.status ?? null} + vramEst={info?.est} + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined } onArrowDownIntoChildren={ - expandedGguf === adapter.id + expandedGguf === id + ? () => focusFirstChildOption(optionKey) + : undefined + } + /> + {expandedGguf === id && ( + + hubModelList.focusOption(optionKey) + } + onNavigatePastEnd={() => + hubModelList.moveFocus(optionKey, "next") + } + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + systemRamGb={gpu.systemRamAvailableGb || undefined} + onDeleteVariant={async (quant) => { + await deleteCachedModel(id, quant); + refreshCachedLists(); + }} + /> + )} +
+ ); + }) + )} + {recommendedSearch.hasMore && ( + <> +
+
+ +
+ + )} + + ) : null} + + {showHfSection && + section === "recommended" && + filteredRecommendedIds.length > 0 ? ( + <> + {filteredRecommendedIds.map((id) => { + const vram = recommendedVramMap.get(id); + const optionKey = makeModelOptionKey( + "search-recommended", + id, + ); + return ( +
+ { + if (isKnownGgufRepo(id)) { + setExpandedGguf((prev) => + prev === id ? null : id, + ); + } else { + handleModelClick(id); + } + }} + vramStatus={ + isKnownGgufRepo(id) ? null : (vram?.status ?? null) + } + vramEst={isKnownGgufRepo(id) ? undefined : vram?.est} + gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} + onArrowDownIntoChildren={ + expandedGguf === id + ? () => { + const focused = + focusFirstChildOption(optionKey); + return focused; + } + : undefined + } + /> + {expandedGguf === id && ( + + hubModelList.focusOption(optionKey) + } + onNavigatePastEnd={() => + hubModelList.moveFocus(optionKey, "next") + } + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + systemRamGb={gpu.systemRamAvailableGb || undefined} + onDeleteVariant={async (quant) => { + await deleteCachedModel(id, quant); + refreshCachedLists(); + }} + /> + )} +
+ ); + })} + + ) : null} + + {showHfSection && section === "recommended" ? ( + <> + {hfIds.length === 0 && !isLoading ? ( + filteredRecommendedIds.length === 0 ? ( +
+ No matching Unsloth models. +
+ ) : null + ) : ( + hfIds.map((id) => { + const vram = vramMap.get(id); + const isSearchGguf = isKnownGgufRepo(id); + const optionKey = makeModelOptionKey("search-hf", id); + return ( +
+ { + if (isSearchGguf) { + setExpandedGguf((prev) => + prev === id ? null : id, + ); + } else { + handleModelClick(id); + } + }} + vramStatus={ + isSearchGguf ? null : (vram?.status ?? null) + } + vramEst={isSearchGguf ? undefined : vram?.est} + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + onArrowDownIntoChildren={ + expandedGguf === id ? () => { - const focused = focusFirstChildOption(optionKey); + const focused = + focusFirstChildOption(optionKey); return focused; } : undefined } /> -
- {canDelete && ( - - This will remove{" "} - - {adapter.name} - {" "} - from disk. This cannot be undone. - - } - successMessage={`Deleted ${adapter.name}`} - disabled={deleteDisabled} - onConfirm={() => - deleteFineTunedModel({ - modelPath: adapter.id, - source: isExported ? "exported" : "training", - exportType: adapter.exportType, - }) - } - onDeleted={() => - onModelsChange?.({ id: adapter.id }) - } - /> - )} -
- {expandedGguf === adapter.id && ( - - loraModelList.focusOption(optionKey) - } - onNavigatePastEnd={() => - loraModelList.moveFocus(optionKey, "next") - } - gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} - systemRamGb={ - gpu.available ? gpu.systemRamAvailableGb : undefined - } - sourceOverride={isExportedGguf ? "exported" : undefined} - deleteVariantTitle="Delete exported GGUF variant?" - renderDeleteVariantDescription={(quant) => ( - <> - This will remove{" "} - - {adapter.name} ({quant}) - {" "} - from disk. This cannot be undone. - + {expandedGguf === id && ( + + hubModelList.focusOption(optionKey) + } + onNavigatePastEnd={() => + hubModelList.moveFocus(optionKey, "next") + } + gpuGb={ + gpu.available ? gpu.memoryTotalGb : undefined + } + systemRamGb={gpu.systemRamAvailableGb || undefined} + onDeleteVariant={async (quant) => { + await deleteCachedModel(id, quant); + refreshCachedLists(); + }} + /> )} - getDeleteVariantSuccessMessage={(quant) => - `Deleted ${adapter.name} ${quant}` - } - deleteDisabled={deleteDisabled} - onDeleteVariant={ - isExportedGguf - ? async (quant) => { - await deleteFineTunedModel({ - modelPath: adapter.id, - source: "exported", - exportType: "gguf", - ggufVariant: quant, - }); - onModelsChange?.({ - id: adapter.id, - ggufVariant: quant, - }); - } - : undefined - } - /> - )} +
+ ); + }) + )} +
+ {isLoadingMore ? ( +
+
- ); - })} -
- )) + ) : null} + + ) : null} + )}
- + {/* Floating eject pill: overlaid on the list bottom, outside the scroll + so the edge fade never touches it. Only the pill catches clicks. */} + {onEject ? ( +
+ +
+ ) : null}
); } + +/** Fine-tuned model rows for the On Device tab's Fine-tuned section. Plugs into + * that section's roving list and shared GGUF-expand state. */ +function FineTunedRows({ + adapters, + value, + onSelect, + onModelsChange, + deleteDisabled = false, + loraModelList, + expandedGguf, + setExpandedGguf, + gpu, +}: { + adapters: LoraModelOption[]; + value?: string; + onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; + onModelsChange?: (deletedModel?: DeletedModelRef) => void; + deleteDisabled?: boolean; + loraModelList: ReturnType; + expandedGguf: string | null; + setExpandedGguf: Dispatch>; + gpu: { + available: boolean; + memoryTotalGb: number; + systemRamAvailableGb: number; + }; +}) { + return ( + <> + {adapters.map((adapter) => { + const isLocal = adapter.source === "local"; + const isTraining = adapter.source === "training"; + const isExported = adapter.source === "exported"; + const isMerged = adapter.exportType === "merged"; + const isGguf = adapter.exportType === "gguf"; + const isExportedGguf = isExported && isGguf; + const canDelete = canDeleteLoraModel(adapter); + const isTrainingFull = isTraining && isMerged; + const isLocalGgufDir = + isLocal && (isGgufRepo(adapter.id) || isGgufRepo(adapter.name)); + const optionKey = makeModelOptionKey("lora", adapter.id); + const tag = isLocal + ? isLocalGgufDir + ? "GGUF" + : "Local" + : isGguf + ? "GGUF" + : isTrainingFull + ? "Full" + : isExported + ? isMerged + ? "Merged" + : "LoRA" + : "LoRA"; + const meta = isLocal + ? isLocalGgufDir + ? "GGUF" + : "Local" + : isTrainingFull + ? "Full finetune" + : isExported + ? `${tag} · Exported` + : tag; + return ( +
+
+
+ { + if (isLocalGgufDir || isExportedGguf) { + setExpandedGguf((prev) => + prev === adapter.id ? null : adapter.id, + ); + } else { + onSelect(adapter.id, { + source: isLocal + ? "local" + : isExported + ? "exported" + : "lora", + isLora: !isLocal && !isMerged && !isGguf, + isDownloaded: true, + }); + } + }} + tooltipText={ + <> + {adapter.name} + + {adapter.id} + + + } + onArrowDownIntoChildren={ + expandedGguf === adapter.id + ? () => { + const focused = focusFirstChildOption(optionKey); + return focused; + } + : undefined + } + /> +
+ {canDelete && ( + + This will remove{" "} + + {adapter.name} + {" "} + from disk. This cannot be undone. + + } + successMessage={`Deleted ${adapter.name}`} + disabled={deleteDisabled} + onConfirm={() => + deleteFineTunedModel({ + modelPath: adapter.id, + source: isExported ? "exported" : "training", + exportType: adapter.exportType, + }) + } + onDeleted={() => onModelsChange?.({ id: adapter.id })} + /> + )} +
+ {expandedGguf === adapter.id && ( + loraModelList.focusOption(optionKey)} + onNavigatePastEnd={() => + loraModelList.moveFocus(optionKey, "next") + } + gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} + systemRamGb={gpu.systemRamAvailableGb || undefined} + sourceOverride={isExportedGguf ? "exported" : undefined} + deleteVariantTitle="Delete exported GGUF variant?" + renderDeleteVariantDescription={(quant) => ( + <> + This will remove{" "} + + {adapter.name} ({quant}) + {" "} + from disk. This cannot be undone. + + )} + getDeleteVariantSuccessMessage={(quant) => + `Deleted ${adapter.name} ${quant}` + } + deleteDisabled={deleteDisabled} + onDeleteVariant={ + isExportedGguf + ? async (quant) => { + await deleteFineTunedModel({ + modelPath: adapter.id, + source: "exported", + exportType: "gguf", + ggufVariant: quant, + }); + onModelsChange?.({ + id: adapter.id, + ggufVariant: quant, + }); + } + : undefined + } + /> + )} +
+ ); + })} + + ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx new file mode 100644 index 0000000000..e6da8a7b74 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +export interface PillTab { + value: string; + label: string; + icon?: ReactNode; +} + +/** Segmented pill toggle reusing the Hub's .hub-tab-toggle styling (extended in + * hub.css to also match .unsloth-model-selector-menu). Keeps tab roles for + * keyboard nav. */ +export function PillTabs({ + tabs, + value, + onValueChange, + ariaLabel, + className, + compact = false, + fit = false, +}: { + tabs: PillTab[]; + value: string; + onValueChange: (value: string) => void; + ariaLabel: string; + className?: string; + compact?: boolean; + /** Size each tab to its label instead of equal widths. The active tab carries + * the pill background directly (the toggle never animates). */ + fit?: boolean; +}) { + const activeIndex = Math.max( + 0, + tabs.findIndex((tab) => tab.value === value), + ); + return ( +
+ {!fit && ( +
+ ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts b/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts new file mode 100644 index 0000000000..24f0edc784 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Pure helpers for the Recommended list: which formats to surface and whether a +// model fits the device. No React/DOM deps so they are easy to test. + +const GGUF_SUFFIX_RE = /-GGUF(?:$|-)/i; +const MLX_RE = /-MLX(?:$|-)/i; + +export function isGgufId(id: string, hintedIsGguf?: boolean): boolean { + return Boolean(hintedIsGguf) || GGUF_SUFFIX_RE.test(id); +} + +export function isMlxId(id: string): boolean { + return MLX_RE.test(id); +} + +// "mobile" build token (e.g. "gemma-4-E4B-it-qat-mobile-GGUF"); bounded so it +// never matches inside a longer word. +const MOBILE_RE = /(?:^|[-_/. ])mobile(?:$|[-_/. ])/i; + +/** A mobile-targeted build, which we keep out of the Recommended list. */ +export function isMobileVariant(id: string): boolean { + return MOBILE_RE.test(id); +} + +/** Recommended only surfaces ready-to-run local formats (GGUF / MLX). */ +export function isRunnableRecommendedFormat( + id: string, + hintedIsGguf?: boolean, +): boolean { + return isGgufId(id, hintedIsGguf) || isMlxId(id); +} + +/** What Recommended is allowed to suggest: GGUF anywhere; on Mac also MLX and + * safetensors (both now run locally there). GPU keeps GGUF-only recommendations. */ +export function isRecommendableFormat( + id: string, + hintedIsGguf: boolean | undefined, + isMac: boolean, +): boolean { + if (isGgufId(id, hintedIsGguf)) return true; + return isMac; +} + +/** Format filter for the listing toggle. "safetensors" means anything that is + * neither GGUF nor MLX. */ +export type FormatFilter = "all" | "gguf" | "mlx" | "safetensors"; + +export function matchesFormatFilter( + id: string, + hintedIsGguf: boolean | undefined, + filter: FormatFilter, +): boolean { + switch (filter) { + case "gguf": + return isGgufId(id, hintedIsGguf); + case "mlx": + return isMlxId(id); + case "safetensors": + return !isGgufId(id, hintedIsGguf) && !isMlxId(id); + default: + return true; + } +} + +// First "B" token in a repo id, e.g. "Qwen3-4B-GGUF" -> 4, "gpt-oss-20b" -> +// 20, "Qwen3-30B-A3B" -> 30 (MoE total), "gemma-4-E4B" -> 4 (effective-param +// "E" series). The digits must be bounded by a separator so we never read "16" +// from "bf16" or the "2" in "Kimi-K2". +const PARAM_RE = /(?:^|[-_/. ])[eE]?(\d+(?:\.\d+)?)\s*[bB](?=$|[-_./ ])/; + +/** Parameter count (absolute, e.g. 4e9) parsed from a repo id, or undefined + * when the id has no size token (so callers can treat the size as unknown). */ +export function paramsFromId(id: string): number | undefined { + const match = PARAM_RE.exec(id); + if (!match) return undefined; + const billions = parseFloat(match[1]); + return Number.isFinite(billions) && billions > 0 ? billions * 1e9 : undefined; +} + +// Smallest practical GGUF/MLX quant (~Q2_K, low-bit). The fit check asks whether +// a model can run at all, so it uses this rather than a default 4-bit size; a +// user with a smaller device can still pick a low-bit variant. +const MIN_QUANT_BYTES_PER_PARAM = 0.4; + +/** Rough on-disk bytes for the smallest practical quant of `params` weights. */ +export function estimateQuantBytes(params: number): number { + return params * MIN_QUANT_BYTES_PER_PARAM; +} + +/** A model fits when its on-disk size (or a precomputed VRAM estimate) is within + * the device budget (0.7*GPU + 0.7*RAM). Unknown device means we cannot tell, so + * treat it as fitting. Unknown size normally fits too, but Recommended passes + * `requireKnown` so a model we cannot size (e.g. a huge GGUF with no metadata or + * size token) is hidden rather than wrongly shown. */ +export function fitsDevice(opts: { + sizeBytes?: number; + estimatedVramGb?: number; + gpuGb?: number; + systemRamGb?: number; + requireKnown?: boolean; +}): boolean { + const { sizeBytes, estimatedVramGb, gpuGb, systemRamGb, requireKnown } = opts; + // Unified-memory hosts (Mac / no discrete GPU) report system RAM but no GPU, + // so the budget must include RAM. Only an entirely unknown budget fits freely. + const budgetGb = Math.max(0, gpuGb ?? 0) * 0.7 + Math.max(0, systemRamGb ?? 0) * 0.7; + if (budgetGb <= 0) return true; + if (sizeBytes && sizeBytes > 0) { + return sizeBytes / 1024 ** 3 <= budgetGb; + } + if (estimatedVramGb && estimatedVramGb > 0) { + return estimatedVramGb <= budgetGb; + } + return requireKnown ? false : true; +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts b/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts new file mode 100644 index 0000000000..85e088ffb2 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Per-model pre-load inference settings, persisted in localStorage so the load +// dialog can offer "Remember settings for ". + +const KEY = "unsloth_load_settings"; + +export interface RememberedLoadSettings { + contextLength: number | null; + kvCacheDtype: string | null; + speculativeType: string | null; + specDraftNMax: number | null; + tensorParallel: boolean; +} + +function readAll(): Record { + try { + return JSON.parse(localStorage.getItem(KEY) ?? "{}"); + } catch { + return {}; + } +} + +function writeAll(all: Record) { + try { + localStorage.setItem(KEY, JSON.stringify(all)); + } catch { + // Ignore quota / unavailable storage. + } +} + +export function loadRememberedLoadSettings( + modelId: string, +): RememberedLoadSettings | null { + return readAll()[modelId] ?? null; +} + +export function saveRememberedLoadSettings( + modelId: string, + settings: RememberedLoadSettings, +) { + const all = readAll(); + all[modelId] = settings; + writeAll(all); +} + +export function clearRememberedLoadSettings(modelId: string) { + const all = readAll(); + if (modelId in all) { + delete all[modelId]; + writeAll(all); + } +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/row-meta.ts b/studio/frontend/src/components/assistant-ui/model-selector/row-meta.ts new file mode 100644 index 0000000000..15a93285cb --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/row-meta.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Pure helpers for model-row presentation: owner/name split, format pills, +// param chip, tabular size. No React/DOM deps so they stay easy to test. + +export type FormatTone = "gguf" | "mlx" | "checkpoint" | "adapter"; + +// Format keyword to DotTag tone. Looked up by full token and by first word, +// so "Full finetune" resolves via "full". +export const FORMAT_TONE: Record = { + gguf: "gguf", + mlx: "mlx", + local: "checkpoint", + safetensors: "checkpoint", + checkpoint: "checkpoint", + lora: "adapter", + merged: "adapter", + adapter: "adapter", + exported: "adapter", + full: "adapter", +}; + +/** Split "owner/name" on the last slash. No slash means name only. */ +export function splitRepoLabel(label: string): { + owner: string | null; + name: string; +} { + const slash = label.lastIndexOf("/"); + if (slash <= 0 || slash === label.length - 1) { + return { owner: null, name: label }; + } + return { owner: label.slice(0, slash), name: label.slice(slash + 1) }; +} + +export type MetaToken = + | { kind: "format"; label: string; tone: FormatTone } + | { kind: "size"; label: string } + | { kind: "param"; label: string } + | { kind: "text"; label: string }; + +const META_SIZE_RE = /(?:KB|MB|GB|TB)\b/i; +const META_APPROX_RE = /^~/; +const META_PARAM_RE = /^\d+(?:\.\d+)?B$/i; +const META_WHITESPACE_RE = /\s+/; + +/** Classify a meta token: size (has KB/MB/GB/TB or leading "~"), param (bare + * "B" like "4B"), format keyword, or plain text. */ +export function classifyMetaToken(raw: string): MetaToken | null { + const t = raw.trim(); + if (!t) return null; + if (META_SIZE_RE.test(t) || META_APPROX_RE.test(t)) { + return { kind: "size", label: t }; + } + if (META_PARAM_RE.test(t)) { + return { kind: "param", label: t.toUpperCase() }; + } + const lower = t.toLowerCase(); + const tone = + FORMAT_TONE[lower] ?? FORMAT_TONE[lower.split(META_WHITESPACE_RE)[0]]; + if (tone) { + return { kind: "format", label: t, tone }; + } + return { kind: "text", label: t }; +} + +/** Parse the dot-separated meta string into structured tokens. */ +export function parseMetaTokens(meta?: string | null): { + formats: { label: string; tone: FormatTone }[]; + param?: string; + size?: string; + texts: string[]; +} { + const formats: { label: string; tone: FormatTone }[] = []; + const texts: string[] = []; + let param: string | undefined; + let size: string | undefined; + if (!meta) return { formats, texts }; + for (const part of meta.split("·")) { + const token = classifyMetaToken(part); + if (!token) continue; + if (token.kind === "format") { + formats.push({ label: token.label, tone: token.tone }); + } else if (token.kind === "size") { + size ??= token.label; + } else if (token.kind === "param") { + param ??= token.label; + } else { + texts.push(token.label); + } + } + return { formats, param, size, texts }; +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/source-tabs.ts b/studio/frontend/src/components/assistant-ui/model-selector/source-tabs.ts new file mode 100644 index 0000000000..8330dbca2e --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/source-tabs.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Pure rules for the source toggle (Hub models / Fine-tuned / Connected). + +export type SourceTab = { value: string; label: string }; + +/** Local models (LM Studio, Ollama, custom folders) are not fine-tuned; they + * live in the Hub tab's Downloaded / Custom sections. */ +export function isFineTunedSource(source?: string): boolean { + return source !== "local"; +} + +/** Build the source tabs. Fine-tuned and Connected models live as sections in + * the Hub tab's toggle, so Hub is the only source and its strip stays hidden. */ +export function buildSourceTabs(): SourceTab[] { + return [{ value: "hub", label: "Hub models" }]; +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts index 6a86e4f7ed..6a86515267 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -31,6 +31,8 @@ export interface ModelSelectorChangeMeta { ggufVariant?: string; isDownloaded?: boolean; expectedBytes?: number; + /** Native GGUF context, threaded so a staged pick can seed the slider. */ + contextLength?: number | null; /** Direct local .gguf file picked without a variant (custom folder / LM * Studio). Marks it as a GGUF source for the deferred-load staging flow. */ isGguf?: boolean; diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 5003072903..589fae5fe9 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -12,8 +12,8 @@ import type { } from "../types"; import type { ApiMonitorEntry, - AudioGenerationResponse, ApiMonitorResponse, + AudioGenerationResponse, GgufVariantsResponse, InferenceStatusResponse, ListLorasResponse, @@ -200,6 +200,9 @@ export interface CachedGgufRepo { /** Epoch seconds of the newest downloaded quant; sorts Downloaded * newest-first. Optional for older-backend compatibility. */ last_modified?: number; + /** True when the repo ships an mmproj adapter (image inputs). Optional for + * older-backend compatibility. */ + has_vision?: boolean; } export async function getGgufDownloadProgress( @@ -278,6 +281,9 @@ export interface LocalModelInfo { path: string; source: "models_dir" | "hf_cache" | "lmstudio" | "custom"; model_id?: string | null; + // Backend-detected weights format ("gguf" when known), so the UI can + // classify scanned folders whose name lacks a -GGUF suffix. + model_format?: string | null; updated_at?: number | null; } @@ -777,6 +783,34 @@ export async function listGgufVariants( return parseJsonOrThrow(response); } +export interface KvCacheEstimate { + kv_bytes: number | null; + weights_bytes: number | null; + native_context: number | null; +} + +/** Estimate KV cache + weight bytes for a downloaded quant at a context length, + * for the load dialog's memory warning. */ +export async function estimateKvCache( + repoId: string, + quant: string, + nCtx: number, + cacheTypeKv?: string | null, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + repo_id: repoId, + quant, + n_ctx: String(nCtx), + }); + if (cacheTypeKv) params.set("cache_type_kv", cacheTypeKv); + const response = await authFetch( + `/api/models/kv-cache-estimate?${params}`, + signal ? { signal } : undefined, + ); + return parseJsonOrThrow(response); +} + function parseSseEvent(rawEvent: string): string[] { const dataLines: string[] = []; for (const line of rawEvent.split(/\r?\n/)) { diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 83452c2ea8..4816e1d515 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -9,6 +9,7 @@ import { ModelSelector, } from "@/components/assistant-ui/model-selector"; import { ProjectComposer, Thread } from "@/components/assistant-ui/thread"; +import { CopyableErrorChip } from "@/components/ui/copyable-error-chip"; import { ResizableHandle, ResizablePanel, @@ -16,18 +17,20 @@ import { } from "@/components/ui/resizable"; import { useSidebar } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; -import { ProjectSourcesPanel } from "@/features/rag/components/project-sources-panel"; +import { useLatestRef } from "@/features/hub/hooks/use-latest-ref"; import { + type NativeIntent, NativeModelChip, NativeModelDropOverlay, - type NativeIntent, useChooseNativeModel, useNativeIntentStore, useNativeModelDrop, useNativePathLeasesSupported, } from "@/features/native-intents"; +import { ProjectSourcesPanel } from "@/features/rag/components/project-sources-panel"; import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { isTauri } from "@/lib/api-base"; +import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; import { BubbleChatTemporaryIcon, @@ -37,7 +40,6 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { useNavigate } from "@tanstack/react-router"; import { Tooltip as TooltipPrimitive } from "radix-ui"; -import type { PanelImperativeHandle } from "react-resizable-panels"; import { type CSSProperties, type ReactElement, @@ -48,10 +50,16 @@ import { useRef, useState, } from "react"; -import { toast } from "@/lib/toast"; +import type { PanelImperativeHandle } from "react-resizable-panels"; import { listLocalModels } from "./api/chat-api"; +import { ArtifactSurface } from "./artifacts/artifact-surface"; +import { + clearAutoOpenedArtifacts, + useChatArtifactsStore, + useSelectedChatArtifact, +} from "./artifacts/store"; +import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types"; import { ChatSettingsPanel } from "./chat-settings-sheet"; -import { CopyableErrorChip } from "@/components/ui/copyable-error-chip"; import { ContextUsageBar } from "./components/context-usage-bar"; import { ModelLoadInlineStatus } from "./components/model-load-status"; import { ProjectSwitcher } from "./components/project-switcher"; @@ -63,12 +71,11 @@ import { import { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; import type { SelectedModelInput } from "./hooks/use-chat-model-runtime"; import { useChatProjects } from "./hooks/use-chat-projects"; -import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation"; -import { useLatestRef } from "@/features/hub/hooks/use-latest-ref"; import { type SidebarItem, useChatSidebarItems, } from "./hooks/use-chat-sidebar-items"; +import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation"; import { clearTrainingCompareHandoff, getTrainingCompareHandoff, @@ -101,19 +108,15 @@ import { CHAT_TOOLS_ENABLED_KEY, CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, hasGgufSource, + isDownloadableHubRepo, loadOptionalBool, + pendingSelectionMatches, useChatRuntimeStore, } from "./stores/chat-runtime-store"; +import type { PendingModelSelection } from "./stores/chat-runtime-store"; import { useChatPreferencesStore } from "./stores/chat-preferences-store"; import { useExternalProvidersStore } from "./stores/external-providers-store"; import { buildChatTourSteps } from "./tour"; -import { ArtifactSurface } from "./artifacts/artifact-surface"; -import { - clearAutoOpenedArtifacts, - useChatArtifactsStore, - useSelectedChatArtifact, -} from "./artifacts/store"; -import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types"; import type { ChatView, MessageRecord } from "./types"; import { getStoredChatThread, @@ -912,7 +915,9 @@ function ProjectLanding({ }, ] as const; } - const messages = await listStoredChatMessages(item.id).catch(() => []); + const messages = await listStoredChatMessages(item.id).catch( + () => [], + ); const firstUserMessage = messages.find((message) => message.role === "user") ?? messages[0]; return [ @@ -1002,41 +1007,41 @@ function ProjectLanding({ {projectTab === "sources" ? ( ) : ( -
- {items.map((item) => { - const preview = previews[item.id]; - return ( - - ); - })} -
+ {preview?.snippet ? ( +
+ {preview.snippet} +
+ ) : null} +
+ + {preview?.date ?? formatProjectChatDate(item.createdAt)} + + + ); + })} +
)}
@@ -1073,11 +1078,16 @@ export function ChatPage({ const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen); const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen); - const loadOnSelection = useChatRuntimeStore((s) => s.loadOnSelection); - const setLoadOnSelection = useChatRuntimeStore((s) => s.setLoadOnSelection); // Deferred-load staging: downloads a staged GGUF (if needed) and reads its // header context so the sheet can show the context slider before the load. - const stagedDownload = useStagedModelPreparation(); + // autoLoad picks instead load the cached file as soon as the download ends; + // selectModel is defined below, so the load runs through a ref. + const autoLoadStagedRef = useRef< + ((pending: PendingModelSelection) => void) | null + >(null); + const stagedDownload = useStagedModelPreparation({ + onAutoLoad: (pending) => autoLoadStagedRef.current?.(pending), + }); // Abandon a staged pick: the store action cancels its in-flight download and // reverts the edited knobs, so nothing lingers after the user walks away. const abandonStaged = useCallback(() => { @@ -1255,6 +1265,28 @@ export function ChatPage({ refreshRef.current = refresh; selectModelRef.current = selectModel; }, [refresh, selectModel]); + // Load a cached autoLoad pick once its download finishes. The sheet was never + // opened, so on a load failure just drop the orphaned staged knobs. + autoLoadStagedRef.current = (pending) => { + void selectModel({ + ...pending, + isDownloaded: true, + forceReload: true, + keepSpeculative: false, + throwOnError: true, + }).catch(() => { + const store = useChatRuntimeStore.getState(); + // selectModel only clears pendingSelection on success, so a failed + // auto-load leaves our staged pick (and its edited load knobs) behind. + // Abandon it when it is still the active stage; otherwise just revert the + // settings if the stage was already cleared by something else. + if (pendingSelectionMatches(store.pendingSelection, pending)) { + store.abandonStagedModel(); + } else if (!store.pendingSelection) { + store.resetModelSettingsToLoaded(); + } + }); + }; const isExternalModel = useMemo( () => isExternalModelId(inferenceParams.checkpoint), [inferenceParams.checkpoint], @@ -1449,7 +1481,9 @@ export function ChatPage({ } if (search.thread) { - const thread = await getStoredChatThread(search.thread).catch(() => null); + const thread = await getStoredChatThread(search.thread).catch( + () => null, + ); if (!canceled) { const projectId = thread?.projectId ?? null; setCurrentProjectId(projectId); @@ -1596,11 +1630,16 @@ export function ChatPage({ const stageOrLoad = useCallback( async (selection: SelectedModelInput) => { const store = useChatRuntimeStore.getState(); - // Only GGUF picks have pre-load options worth staging. Non-GGUF models - // (and the toggle-on case) load immediately, so e.g. a trust_remote_code - // approval surfaces through the normal load path. - if (store.loadOnSelection || !hasGgufSource(selection)) { - // Abandon any staged GGUF first so its edited knobs (e.g. a custom + // An un-cached HF repo (GGUF variant or a full non-GGUF snapshot) downloads + // through the manager first (global indicator), then auto-loads. Everything + // else -- cached picks, local/native files, LoRA, external -- loads now. + const wantManagerDownload = + isDownloadableHubRepo(selection) && !selection.isDownloaded; + if ( + (!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(); @@ -1616,7 +1655,8 @@ export function ChatPage({ 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. + // 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(); store.stageModel({ id: selection.id, @@ -1626,6 +1666,8 @@ export function ChatPage({ expectedBytes: selection.expectedBytes, nativePathToken: selection.nativePathToken, isGguf: selection.isGguf, + isHubRepo: wantManagerDownload || undefined, + autoLoad: store.loadOnSelection, }); }, [abandonStaged, selectModel], @@ -1719,8 +1761,7 @@ export function ChatPage({ selectedProvider?.providerType, selectedExternal?.modelId, { - isReasoningProvider: - selectedProvider?.isReasoningModel === true, + isReasoningProvider: selectedProvider?.isReasoningModel === true, baseUrl: selectedProvider?.baseUrl ?? null, }, ); @@ -1878,6 +1919,7 @@ export function ChatPage({ } const selection = { id: value, + source: meta?.source, isLora: meta?.isLora, ggufVariant: meta?.ggufVariant, isDownloaded: meta?.isDownloaded, @@ -1977,8 +2019,7 @@ export function ChatPage({ if (!usage) return; const store = useChatRuntimeStore.getState(); const activeCheckpoint = store.params.checkpoint; - const usageModelId = - (usage as { modelId?: unknown }).modelId; + const usageModelId = (usage as { modelId?: unknown }).modelId; // Scope by modelId when present; reject if no active checkpoint // (model-scoped usage can't be attributed to "nothing"). if (typeof usageModelId === "string" && usageModelId) { @@ -2262,7 +2303,7 @@ export function ChatPage({ beneath it, instead of a hard cut. */} {view.mode !== "compare" && (
)} @@ -2284,8 +2325,6 @@ export function ChatPage({ activeGgufVariant={activeGgufVariant} onValueChange={handleCheckpointChange} onEject={handleEject} - loadOnSelection={loadOnSelection} - onLoadOnSelectionChange={setLoadOnSelection} onFoldersChange={refreshLocalModels} onPickLocalModel={isTauri ? chooseNativeModel : undefined} onModelsChange={refreshModelLists} @@ -2313,7 +2352,7 @@ export function ChatPage({ /> {currentProject && activeThreadId ? ( <> - + / @@ -2555,7 +2594,8 @@ export function ChatPage({ stagedDownloadFraction={stagedDownload.progress?.fraction ?? null} onCancelStagedDownload={() => stagedDownload.cancelDownload( - useChatRuntimeStore.getState().pendingSelection?.ggufVariant ?? null, + useChatRuntimeStore.getState().pendingSelection?.ggufVariant ?? + null, ) } /> diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 77d7cf4c6d..bd315e19d4 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -17,6 +17,12 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + clearRememberedLoadSettings, + loadRememberedLoadSettings, + saveRememberedLoadSettings, +} from "@/components/assistant-ui/model-selector/remembered-load-settings"; import { Dialog, DialogContent, @@ -520,9 +526,13 @@ export function ChatSettingsPanel({ })(); const isLoadedGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; - const isGguf = isLoadedGguf || pendingIsGguf; - // A staged pick is always a local GGUF, so show its Model section (and the - // Load button) even when the currently active model is external. + // While a pick is staged the sheet configures *that* model, so its GGUF-ness + // (not the currently loaded model's) decides whether the GGUF-only controls + // show. Otherwise a staged non-GGUF Hub repo would inherit the loaded GGUF's + // context/KV/speculative controls. + const isGguf = pendingSelection != null ? pendingIsGguf : isLoadedGguf; + // The Model section (and Load button) shows for any staged pick, even when the + // currently active model is external. const hasModelContent = pendingSelection != null || (!isExternalModel && (isGguf || Boolean(params.checkpoint))); @@ -599,6 +609,29 @@ export function ChatSettingsPanel({ // pendingSelection, so the slider can use the staged model's real ceiling // without reading the loaded model's `ggufContextLength`. const stagedContextLength = pendingSelection?.contextLength ?? null; + // "Remember settings next time" tick for a staged model. Seeds the store from + // the saved per-model settings on stage, so the sheet opens with what was used + // last time; the tick reflects whether a saved entry exists. + const [remember, setRemember] = useState(false); + const pendingId = pendingSelection?.id ?? null; + useEffect(() => { + if (!pendingId) return; + const saved = loadRememberedLoadSettings(pendingId); + setRemember(saved != null); + if (!saved) return; + setCustomContextLength(saved.contextLength); + setKvCacheDtype(saved.kvCacheDtype); + setSpeculativeType(saved.speculativeType ?? "auto"); + setSpecDraftNMax(saved.specDraftNMax); + setTensorParallel(saved.tensorParallel); + }, [ + pendingId, + setCustomContextLength, + setKvCacheDtype, + setSpeculativeType, + setSpecDraftNMax, + setTensorParallel, + ]); // While staging, the sheet reflects the STAGED model, so its header context // takes precedence over the loaded model's (which may differ or be larger). const baseContext = pendingIsGguf ? stagedContextLength : ggufContextLength; @@ -1148,34 +1181,61 @@ export function ChatSettingsPanel({ staged (deferred load), Load/Cancel takes its place: there's nothing loaded to "apply" against yet. */} {pendingSelection ? ( -
+
{stagedDownloading && (

Downloading…{" "} {Math.round((stagedDownloadFraction ?? 0) * 100)}%

)} + {stagedLoading ? ( // Mid-load: nothing to load or abandon until it settles, so disable. ) : ( -
+
@@ -1189,7 +1249,7 @@ export function ChatSettingsPanel({ if (stagedDownloading) onCancelStagedDownload?.(); abandonStagedModel(); }} - className="h-7 px-3 text-[12px] font-medium tracking-nav text-muted-foreground" + className="h-9 w-full rounded-full text-[13px] font-medium tracking-nav text-muted-foreground" > Cancel diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index bbf106400c..e23d1b0b33 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -25,6 +25,7 @@ import { } from "../api/chat-api"; import { formatEta, formatRate } from "../utils/format-transfer"; import { + isLocalModelPath, pendingSelectionMatches, readPersistedSpeculativeType, resolveToolsEnabledOnLoad, @@ -57,6 +58,11 @@ export type SelectedModelInput = { id: string; isLora?: boolean; ggufVariant?: string; + /** Where the pick came from (e.g. "hub", "local", "external"). Used to decide + * whether an uncached repo should download via the Hub manager. */ + source?: string; + /** Uncached non-GGUF HF repo staged for a snapshot download (variant null). */ + isHubRepo?: boolean; loadingDescription?: string; isDownloaded?: boolean; expectedBytes?: number; @@ -484,8 +490,7 @@ export function useChatModelRuntime() { : undefined; const previousIsLora = previousModel?.isLora ?? (previousLora?.exportType === "lora"); - // Covers Unix absolute (/), relative (./ ../), tilde (~/), Windows drive (C:\), UNC (\\server) - const isLocal = /^(\/|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\)/.test(modelId); + const isLocal = isLocalModelPath(modelId); const isCachedLora = isLora && isLocal; const loadingDescription = [ currentCheckpoint ? "Switching models." : null, diff --git a/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts b/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts index b11f496aa5..d8076c720b 100644 --- a/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts +++ b/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts @@ -3,9 +3,9 @@ import { useCallback, useEffect } from "react"; -import { useLatestRef } from "@/features/hub/hooks/use-latest-ref"; import { useRepoDownload } from "@/features/hub/download-manager/use-repo-download"; import type { DownloadJob } from "@/features/hub/download-manager/use-repo-download"; +import { useLatestRef } from "@/features/hub/hooks/use-latest-ref"; import { fetchGgufContextLength } from "../api/chat-api"; import { @@ -13,6 +13,7 @@ import { pendingSelectionMatches, useChatRuntimeStore, } from "../stores/chat-runtime-store"; +import type { PendingModelSelection } from "../stores/chat-runtime-store"; /** * Drives the deferred ("Load on selection" off) staging flow for a GGUF: @@ -23,7 +24,10 @@ import { * the loaded model's `ggufContextLength`). Returns the live download job so the * sheet can render progress / cancel. Mount once on the chat page. */ -export function useStagedModelPreparation(): DownloadJob { +export function useStagedModelPreparation(opts?: { + /** Load the cached file once an autoLoad pick's download completes. */ + onAutoLoad?: (pending: PendingModelSelection) => void; +}): DownloadJob { const pendingId = useChatRuntimeStore((s) => s.pendingSelection?.id ?? null); const pendingVariant = useChatRuntimeStore( (s) => s.pendingSelection?.ggufVariant ?? null, @@ -35,6 +39,10 @@ export function useStagedModelPreparation(): DownloadJob { const pendingIsGguf = useChatRuntimeStore((s) => isPendingGguf(s.pendingSelection), ); + // Non-GGUF HF repos download a full snapshot (variant null) but have no header. + const pendingIsHubRepo = useChatRuntimeStore( + (s) => s.pendingSelection?.isHubRepo ?? false, + ); const pendingDownloaded = useChatRuntimeStore( (s) => s.pendingSelection?.isDownloaded ?? false, ); @@ -42,6 +50,19 @@ export function useStagedModelPreparation(): DownloadJob { (s) => s.pendingSelection?.contextLength != null, ); const setPendingSelection = useChatRuntimeStore((s) => s.setPendingSelection); + const onAutoLoadRef = useLatestRef(opts?.onAutoLoad); + + // A failed or cancelled autoLoad download has no sheet to retry from, so drop + // the staged pick rather than leave it waiting on a load that won't come. + const handleAutoLoadAbort = useCallback((variant: string | null) => { + const latest = useChatRuntimeStore.getState().pendingSelection; + if ( + latest?.autoLoad && + (latest.ggufVariant ?? null) === (variant ?? null) + ) { + useChatRuntimeStore.getState().abandonStagedModel(); + } + }, []); const fetchContextMetadata = useCallback(async () => { const current = useChatRuntimeStore.getState().pendingSelection; @@ -76,9 +97,21 @@ export function useStagedModelPreparation(): DownloadJob { // inert until something is staged. repoId: pendingId ?? "__staged_idle__", activeVariant: pendingVariant, - onComplete: () => { + onComplete: (variant) => { + // autoLoad picks load the cached file now; staged picks read the header so + // the sheet's context slider can show before a manual load. + const latest = useChatRuntimeStore.getState().pendingSelection; + if ( + latest?.autoLoad && + (latest.ggufVariant ?? null) === (variant ?? null) + ) { + onAutoLoadRef.current?.(latest); + return; + } void fetchContextMetadata(); }, + onError: handleAutoLoadAbort, + onCancelled: handleAutoLoadAbort, }); // job.requestStartDownload's identity changes per render; hold it in a ref so @@ -87,9 +120,18 @@ export function useStagedModelPreparation(): DownloadJob { const fetchMetadataRef = useLatestRef(fetchContextMetadata); useEffect(() => { - if (!pendingId || !pendingIsGguf || pendingHasContext) return; + // GGUF picks (header worth reading) and uncached non-GGUF hub repos (full + // snapshot, no header) both run here; everything else is loaded directly. + if ( + !pendingId || + (!pendingIsGguf && !pendingIsHubRepo) || + pendingHasContext + ) { + return; + } // Native files and already-downloaded HF files are local: read the header - // now. Otherwise download first; onComplete then reads it. + // now. Otherwise download first (a GGUF variant, or a null-variant snapshot + // for a hub repo); onComplete then reads the header or auto-loads. if (pendingNativeToken || pendingDownloaded) { void fetchMetadataRef.current(); } else { @@ -102,6 +144,7 @@ export function useStagedModelPreparation(): DownloadJob { pendingVariant, pendingNativeToken, pendingIsGguf, + pendingIsHubRepo, pendingDownloaded, pendingHasContext, startDownloadRef, diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index 9b7ca08c1b..9386650fee 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -175,6 +175,9 @@ export function applyActiveModelStatusToStore( status.chat_template === undefined ? prevState.defaultChatTemplate : status.chat_template; + // While a load is in flight, performLoad owns the load params. Seeding them + // from a stale poll here would clobber the values the load dialog just set. + const seedLoadParams = !prevState.modelLoading; useChatRuntimeStore.setState({ supportsReasoning, @@ -199,22 +202,26 @@ export function applyActiveModelStatusToStore( loadedIsMultimodal: isMultimodalResponse(status), loadedIsDiffusion: status.is_diffusion ?? false, specFallbackReason: status.spec_fallback_reason ?? null, - ...(prevState.loadedSpeculativeType === null && { - speculativeType: currentSpecType, - loadedSpeculativeType: currentSpecType, - }), - ...(status.spec_draft_n_max !== undefined && + ...(seedLoadParams && + prevState.loadedSpeculativeType === null && { + speculativeType: currentSpecType, + loadedSpeculativeType: currentSpecType, + }), + ...(seedLoadParams && + status.spec_draft_n_max !== undefined && prevState.loadedSpecDraftNMax === null && prevState.specDraftNMax === null && { specDraftNMax: status.spec_draft_n_max ?? null, loadedSpecDraftNMax: status.spec_draft_n_max ?? null, }), - ...(status.cache_type_kv !== undefined && + ...(seedLoadParams && + status.cache_type_kv !== undefined && prevState.loadedKvCacheDtype === null && { kvCacheDtype: status.cache_type_kv, loadedKvCacheDtype: status.cache_type_kv, }), - ...(status.tensor_parallel !== undefined && + ...(seedLoadParams && + status.tensor_parallel !== undefined && prevState.loadedTensorParallel === null && { tensorParallel: status.tensor_parallel, loadedTensorParallel: status.tensor_parallel, diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx index 87fe9b66ec..1651b49626 100644 --- a/studio/frontend/src/features/chat/projects-page.tsx +++ b/studio/frontend/src/features/chat/projects-page.tsx @@ -302,13 +302,13 @@ export function ProjectsPage() { {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( void handleBulkProjectExport("projects", fmt, true)}> - {label} — combined + {label} (combined) ))} {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( void handleBulkProjectExport("projects", fmt, false)}> - {label} — per chat + {label} (per chat) ))} @@ -318,13 +318,13 @@ export function ProjectsPage() { {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( void handleBulkProjectExport("all", fmt, true)}> - {label} — combined + {label} (combined) ))} {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( void handleBulkProjectExport("all", fmt, false)}> - {label} — per chat + {label} (per chat) ))} @@ -586,7 +586,8 @@ export function ProjectsPage() { Import chats

- {importFile?.name} — choose where to import: + Choose where to import{" "} + {importFile?.name}:

+
+ setDatasetStreaming(!!v)} + /> + + + + + + + {isStreamingSupported ? ( + + Stream Hugging Face text datasets instead of + downloading them. + + ) : ( +
+

+ Streaming unavailable. To enable: +

+
    + {streamingBlockers.map((reason) => ( +
  • {reason}
  • + ))} +
+
+ )} +
+
+
diff --git a/studio/frontend/src/features/studio/sections/params-section.tsx b/studio/frontend/src/features/studio/sections/params-section.tsx index f254921fa6..059d665122 100644 --- a/studio/frontend/src/features/studio/sections/params-section.tsx +++ b/studio/frontend/src/features/studio/sections/params-section.tsx @@ -1067,11 +1067,22 @@ export function ParamsSection(): ReactElement { store.setTrainOnCompletions(!!v)} /> diff --git a/studio/frontend/src/features/studio/training-start-overlay.tsx b/studio/frontend/src/features/studio/training-start-overlay.tsx index 3b0cce658b..c69b25577c 100644 --- a/studio/frontend/src/features/studio/training-start-overlay.tsx +++ b/studio/frontend/src/features/studio/training-start-overlay.tsx @@ -263,6 +263,10 @@ export function TrainingStartOverlay({ const configuredModel = useTrainingConfigStore((s) => s.selectedModel); const datasetSource = useTrainingConfigStore((s) => s.datasetSource); const dataset = useTrainingConfigStore((s) => s.dataset); + // Streaming runs never fully download the dataset (only small metadata lands + // in the HF cache), so the cache-watching download bar would sit near 0% + // forever and read as "stuck downloading". Show a streaming note instead. + const datasetStreaming = useTrainingConfigStore((s) => s.datasetStreaming); // Only HF datasets have a download phase to track; uploaded files are already // on disk by the time the overlay shows up. const hfDatasetName = datasetSource === "huggingface" ? dataset : null; @@ -380,7 +384,11 @@ export function TrainingStartOverlay({ step: currentStep, })} - {datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? ( + {datasetStreaming ? ( + + {t("studio.trainingStart.datasetStreaming")} + + ) : datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? ( = new Set "isDatasetAudio", "trainOnCompletions", "maxPositionEmbeddings", + "isVisionModel", "s3Config", ]); @@ -165,6 +168,68 @@ function canProceedForStep(state: TrainingConfigState): boolean { } } +// Single source of truth for the "streaming + eval needs a distinct split" +// rule. Shared between the store's compatibility patch and the UI gate +// (DatasetSection) so the two never drift apart. +export function hasSeparateStreamingEvalSplit( + state: Pick< + TrainingConfigState, + "evalSteps" | "datasetSplit" | "datasetEvalSplit" + >, +): boolean { + if (state.evalSteps <= 0) return true; + const trainSplit = state.datasetSplit || "train"; + return !!state.datasetEvalSplit && state.datasetEvalSplit !== trainSplit; +} + +function streamingCompatiblePatch( + state: TrainingConfigState, +): Partial { + const patch: Partial = {}; + + if (state.datasetStreaming && state.maxSteps <= 0) { + patch.datasetStreaming = false; + } + + // Evaluate the remaining streaming constraints against the *post-patch* + // streaming value. If streaming is being turned off in this same patch + // (e.g. maxSteps dropped to 0), its other constraints are moot and we must + // NOT clobber unrelated user preferences like trainOnCompletions/evalSteps. + const willStream = + patch.datasetStreaming !== undefined + ? patch.datasetStreaming + : state.datasetStreaming; + + if (willStream && state.trainOnCompletions) { + patch.trainOnCompletions = false; + } + + if (willStream && !hasSeparateStreamingEvalSplit(state)) { + patch.evalSteps = 0; + } + + return patch; +} + +// streamingCompatiblePatch can silently flip streaming-coupled fields. Surface a +// toast when it does, so the indirect setters (split / eval-split / max-steps / +// eval-steps) match setDatasetStreaming's "tell the user what changed" behavior. +function notifyStreamingCompat(patch: Partial): void { + if (patch.datasetStreaming === false) { + toast.info("Streaming turned off: streaming needs a fixed Max Steps > 0."); + return; + } + const disabled = [ + patch.trainOnCompletions === false && "assistant-completions-only", + patch.evalSteps === 0 && "evaluation (needs a separate eval split)", + ].filter(Boolean); + if (disabled.length > 0) { + toast.info( + `Adjusted for streaming. Disabled incompatible options: ${disabled.join(", ")}.`, + ); + } +} + type TrainingMethodStatePatch = Partial< Pick< TrainingConfigState, @@ -649,15 +714,19 @@ export const useTrainingConfigStore = create()( }); }, setDatasetSplit: (datasetSplit) => { + const state = get(); + const nextState = { ...state, datasetSplit }; + const streamingPatch = streamingCompatiblePatch(nextState); set({ datasetSplit, datasetManualMapping: emptyManualMapping(), isDatasetImage: null, isDatasetAudio: false, isCheckingDataset: false, + ...streamingPatch, }); + notifyStreamingCompat(streamingPatch); - const state = get(); const datasetName = state.datasetSource === "huggingface" ? state.dataset @@ -681,10 +750,53 @@ export const useTrainingConfigStore = create()( runDatasetCheck(datasetName, split); }, setDatasetEvalSplit: (datasetEvalSplit) => { + const state = get(); + const evalSteps = datasetEvalSplit ? 0.1 : 0; + const streamingPatch = streamingCompatiblePatch({ + ...state, + datasetEvalSplit, + evalSteps, + }); set({ datasetEvalSplit, - evalSteps: datasetEvalSplit ? 0.1 : 0, + evalSteps, + ...streamingPatch, }); + notifyStreamingCompat(streamingPatch); + }, + setDatasetStreaming: (datasetStreaming) => { + if (!datasetStreaming) { + set({ datasetStreaming: false }); + return; + } + + const state = get(); + if (state.maxSteps <= 0) { + set({ datasetStreaming: false }); + toast.warning( + "Streaming needs a fixed Max Steps (streaming datasets have no known length). Set Max Steps > 0 first.", + ); + return; + } + + const dropsTrainOnCompletions = state.trainOnCompletions; + const dropsEval = !hasSeparateStreamingEvalSplit(state); + + set({ + datasetStreaming: true, + trainOnCompletions: false, + evalSteps: dropsEval ? 0 : state.evalSteps, + }); + + if (dropsTrainOnCompletions || dropsEval) { + const disabled = [ + dropsTrainOnCompletions && "assistant-completions-only", + dropsEval && "evaluation (needs a separate eval split)", + ].filter(Boolean); + toast.info( + `Streaming enabled. Disabled incompatible options: ${disabled.join(", ")}.`, + ); + } }, setDatasetManualMapping: (datasetManualMapping) => set({ datasetManualMapping }), @@ -748,13 +860,34 @@ export const useTrainingConfigStore = create()( set({ gradientAccumulation }), setWeightDecay: (weightDecay) => set({ weightDecay }), setWarmupSteps: (warmupSteps) => set({ warmupSteps }), - setMaxSteps: (maxSteps) => set({ maxSteps }), + setMaxSteps: (maxSteps) => { + const state = get(); + // streamingCompatiblePatch already turns streaming off when maxSteps<=0, + // so no separate datasetStreaming reset is needed here. + const streamingPatch = streamingCompatiblePatch({ ...state, maxSteps }); + set({ + maxSteps, + ...streamingPatch, + }); + notifyStreamingCompat(streamingPatch); + }, setSaveSteps: (saveSteps) => set({ saveSteps }), - setEvalSteps: (evalSteps) => set({ evalSteps }), + setEvalSteps: (evalSteps) => { + const state = get(); + const streamingPatch = streamingCompatiblePatch({ ...state, evalSteps }); + set({ + evalSteps, + ...streamingPatch, + }); + notifyStreamingCompat(streamingPatch); + }, setPacking: (packing) => set({ packing }), setTrainOnCompletions: (trainOnCompletions) => { _trainOnCompletionsManuallySet = true; - set({ trainOnCompletions }); + set({ + trainOnCompletions, + ...(trainOnCompletions ? { datasetStreaming: false } : {}), + }); }, setGradientCheckpointing: (gradientCheckpointing) => set({ gradientCheckpointing }), @@ -805,7 +938,7 @@ export const useTrainingConfigStore = create()( }, { name: "unsloth_training_config_v1", - version: 10, + version: 11, migrate: (persisted, version) => { const s = persisted as Record; if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) { @@ -852,9 +985,31 @@ export const useTrainingConfigStore = create()( s.learningRate = LR_DEFAULT_CPT; } } + if (version < 11) { + // Standalone bump: users already on main's v10 (CPT) skipped the + // streaming backfill when it was nested under v<10, so give it its + // own version guard. + s.datasetStreaming ??= false; + } return s as unknown as TrainingConfigStore; }, partialize: partializePersistedState, + onRehydrateStorage: () => (state) => { + // datasetStreaming is persisted, but constraint-coupled fields like + // trainOnCompletions / maxSteps / evalSteps are NON_PERSISTED and + // rehydrate to defaults. That can resurrect an invalid combo (e.g. + // streaming=true with a default trainOnCompletions) that the backend + // rejects with 422. Reconcile immediately on load instead of relying + // on a post-mount effect. + if (!state) return; + const patch = streamingCompatiblePatch(state); + if (Object.keys(patch).length > 0) { + // Sync localStorage hydration runs inside create(), before + // useTrainingConfigStore is assigned (TDZ). Defer to a microtask so the + // store exists when we reconcile the persisted streaming combo. + queueMicrotask(() => useTrainingConfigStore.setState(patch)); + } + }, }, ), ); diff --git a/studio/frontend/src/features/training/types/api.ts b/studio/frontend/src/features/training/types/api.ts index 1d90a3647f..4f7a41bdea 100644 --- a/studio/frontend/src/features/training/types/api.ts +++ b/studio/frontend/src/features/training/types/api.ts @@ -18,6 +18,7 @@ export interface TrainingStartRequest { subset: string | null; train_split: string | null; eval_split: string | null; + dataset_streaming: boolean; dataset_slice_start: number | null; dataset_slice_end: number | null; local_datasets: string[]; diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts index 68ede77d6e..d24ce31a6a 100644 --- a/studio/frontend/src/features/training/types/config.ts +++ b/studio/frontend/src/features/training/types/config.ts @@ -29,6 +29,7 @@ export interface TrainingConfigState { datasetSubset: string | null; datasetSplit: string | null; datasetEvalSplit: string | null; + datasetStreaming: boolean; datasetManualMapping: DatasetManualMapping; datasetSystemPrompt: string; datasetUserTemplate: string; @@ -107,6 +108,7 @@ export interface TrainingConfigActions { setDatasetSubset: (subset: string | null) => void; setDatasetSplit: (split: string | null) => void; setDatasetEvalSplit: (split: string | null) => void; + setDatasetStreaming: (value: boolean) => void; setDatasetManualMapping: (mapping: DatasetManualMapping) => void; setDatasetAdvisorFields: (fields: { systemPrompt?: string; diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 7bc86b57ec..1ea352f081 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -845,6 +845,7 @@ export const en = { resumingTraining: "Resuming training...", startingTraining: "starting training...", dataset: "Dataset", + datasetStreaming: "Dataset: streaming (no full download)", modelWeights: "Model weights", }, tour: { diff --git a/tests/python/test_e2e_no_torch_sandbox.py b/tests/python/test_e2e_no_torch_sandbox.py index 7da7cf0eae..23d4a2c2b3 100644 --- a/tests/python/test_e2e_no_torch_sandbox.py +++ b/tests/python/test_e2e_no_torch_sandbox.py @@ -27,6 +27,7 @@ CHAT_TEMPLATES = DATASETS_DIR / "chat_templates.py" FORMAT_DETECTION = DATASETS_DIR / "format_detection.py" MODEL_MAPPINGS = DATASETS_DIR / "model_mappings.py" VLM_PROCESSING = DATASETS_DIR / "vlm_processing.py" +ITERABLE = DATASETS_DIR / "iterable.py" HARDWARE_PY = HARDWARE_DIR / "hardware.py" # Studio venv for server tests @@ -280,9 +281,13 @@ class TestBeforeAfterImportChain: mm = types.ModuleType('model_mappings') mm.MODEL_TO_TEMPLATE_MAPPER = {{}} sys.modules['model_mappings'] = mm + it = types.ModuleType('iterable') + it.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = it source = open({str(CHAT_TEMPLATES)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') source = source.replace('from .model_mappings import', 'from model_mappings import') + source = source.replace('from .iterable import', 'from iterable import') exec(source) print("OK") """) @@ -323,6 +328,7 @@ class TestBeforeAfterImportChain: VLM_PROCESSING, DATA_COLLATORS, CHAT_TEMPLATES, + ITERABLE, ]: if src.exists(): shutil.copy2(src, pkg_dir / src.name) @@ -431,10 +437,14 @@ class TestDataclassInstantiation: mm = types.ModuleType('model_mappings') mm.MODEL_TO_TEMPLATE_MAPPER = {{}} sys.modules['model_mappings'] = mm + it = types.ModuleType('iterable') + it.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = it ns = {{}} source = open({str(CHAT_TEMPLATES)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') source = source.replace('from .model_mappings import', 'from model_mappings import') + source = source.replace('from .iterable import', 'from iterable import') exec(source, ns) assert 'Instruction' in ns['DEFAULT_ALPACA_TEMPLATE'] print("OK") @@ -544,11 +554,15 @@ class TestEdgeCasesBrokenTorch: mm = types.ModuleType('model_mappings') mm.MODEL_TO_TEMPLATE_MAPPER = {{}} sys.modules['model_mappings'] = mm + it = types.ModuleType('iterable') + it.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = it ns = {{}} source = open({str(CHAT_TEMPLATES)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') source = source.replace('from .model_mappings import', 'from model_mappings import') + source = source.replace('from .iterable import', 'from iterable import') exec(source, ns) # Import succeeds -- this is the fix diff --git a/tests/python/test_studio_import_no_torch.py b/tests/python/test_studio_import_no_torch.py index 86dc8581ab..c4efbc8cea 100644 --- a/tests/python/test_studio_import_no_torch.py +++ b/tests/python/test_studio_import_no_torch.py @@ -254,10 +254,15 @@ class TestChatTemplatesNoTorchVenv: model_mappings.MODEL_TO_TEMPLATE_MAPPER = {{}} sys.modules['model_mappings'] = model_mappings + iterable = types.ModuleType('iterable') + iterable.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = iterable + # Read and transform the source: replace relative imports with absolute source = open({str(CHAT_TEMPLATES)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') source = source.replace('from .model_mappings import', 'from model_mappings import') + source = source.replace('from .iterable import', 'from iterable import') exec(source) @@ -295,10 +300,15 @@ class TestChatTemplatesNoTorchVenv: model_mappings.MODEL_TO_TEMPLATE_MAPPER = {{}} sys.modules['model_mappings'] = model_mappings + iterable = types.ModuleType('iterable') + iterable.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = iterable + ns = {{}} source = open({str(CHAT_TEMPLATES)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') source = source.replace('from .model_mappings import', 'from model_mappings import') + source = source.replace('from .iterable import', 'from iterable import') exec(source, ns) assert 'DEFAULT_ALPACA_TEMPLATE' in ns, "DEFAULT_ALPACA_TEMPLATE not defined" @@ -379,6 +389,10 @@ class TestFormatConversionNoTorchVenv: datasets_mod.IterableDataset = type('IterableDataset', (), {{}}) sys.modules['datasets'] = datasets_mod + iterable_mod = types.ModuleType('iterable') + iterable_mod.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = iterable_mod + # Stub utils.hardware utils_mod = types.ModuleType('utils') hardware_mod = types.ModuleType('utils.hardware') @@ -390,6 +404,7 @@ class TestFormatConversionNoTorchVenv: # Read and exec format_conversion.py source = open({str(FORMAT_CONVERSION)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .iterable import', 'from iterable import') ns = {{'__name__': '__test__'}} exec(source, ns) @@ -437,6 +452,10 @@ class TestFormatConversionNoTorchVenv: datasets_mod.IterableDataset = type('IterableDataset', (), {{}}) sys.modules['datasets'] = datasets_mod + iterable_mod = types.ModuleType('iterable') + iterable_mod.is_streaming_dataset = lambda *a, **k: False + sys.modules['iterable'] = iterable_mod + utils_mod = types.ModuleType('utils') hardware_mod = types.ModuleType('utils.hardware') hardware_mod.dataset_map_num_proc = lambda n=None: 1 @@ -446,6 +465,7 @@ class TestFormatConversionNoTorchVenv: source = open({str(FORMAT_CONVERSION)!r}).read() source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .iterable import', 'from iterable import') ns = {{'__name__': '__test__'}} exec(source, ns) From 494e0e6fe4a50aa2082bada63bc0f1864b11c14b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 07:49:13 -0700 Subject: [PATCH 034/306] studio: let users change their password from Settings (#6520) * studio: let users change their password from Settings The only day-to-day way to change credentials was the destructive console command 'unsloth studio reset-password' (it deletes auth.db); the in-app change-password page is the forced first-login flow and bounces non-forced users to /login. Add a Change password control to Settings > General > Account: a small dialog that takes the current and new password and calls the existing POST /api/auth/change-password, then stores the rotated tokens it returns. Username changes remain out of scope. The dialog uses authFetch, so an expired access token is refreshed and the request retried instead of failing with a spurious expired-token error for a user who left Studio open past the token lifetime. The row is hidden in the Tauri desktop app, which authenticates via desktop auto-auth with a generated secret: there is no user-entered password to change there, and changing it would clear the desktop secret. * studio: harden settings password change * studio: harden settings password dialog UX --------- Co-authored-by: wasimysaid --- studio/frontend/src/features/auth/index.ts | 1 + .../src/features/native-intents/index.ts | 1 + .../components/change-password-dialog.tsx | 323 ++++++++++++++++++ .../features/settings/tabs/general-tab.tsx | 14 +- studio/frontend/src/i18n/locales/en.ts | 21 ++ studio/frontend/src/i18n/locales/zh-CN.ts | 18 + 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 studio/frontend/src/features/settings/components/change-password-dialog.tsx diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 9baad33e0e..f33991b6b7 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -15,6 +15,7 @@ export { mustChangePassword, resetOnboardingDone, setMustChangePassword, + storeAuthTokens, } from "./session"; export { clearTauriAuthFailure, diff --git a/studio/frontend/src/features/native-intents/index.ts b/studio/frontend/src/features/native-intents/index.ts index 1e62dc26e5..a82c39a9ec 100644 --- a/studio/frontend/src/features/native-intents/index.ts +++ b/studio/frontend/src/features/native-intents/index.ts @@ -3,6 +3,7 @@ export { NativeModelChip } from "./components/native-model-chip"; export { NativeModelDropOverlay } from "./components/native-model-drop-overlay"; +export { openModelsDir } from "./api"; export { useNativeIntentStore } from "./store"; export type { NativeIntent } from "./types"; export { useChooseNativeModel } from "./use-native-dialogs"; diff --git a/studio/frontend/src/features/settings/components/change-password-dialog.tsx b/studio/frontend/src/features/settings/components/change-password-dialog.tsx new file mode 100644 index 0000000000..cd30d37d5d --- /dev/null +++ b/studio/frontend/src/features/settings/components/change-password-dialog.tsx @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + getAuthToken, + refreshSession, + setMustChangePassword, + storeAuthTokens, +} from "@/features/auth"; +import { useT } from "@/i18n"; +import { apiUrl } from "@/lib/api-base"; +import { toast } from "@/lib/toast"; +import { type FormEvent, useState } from "react"; + +const MIN_PASSWORD_LENGTH = 8; +const WRONG_CURRENT_PASSWORD_DETAIL = "Current password is incorrect"; + +type T = ReturnType; + +function stringField(payload: Record, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; +} + +function booleanField(payload: Record, key: string): boolean { + return payload[key] === true; +} + +function changePasswordBody( + currentPassword: string, + nextPassword: string, +): string { + return JSON.stringify( + Object.fromEntries([ + ["current_password", currentPassword], + ["new_password", nextPassword], + ]), + ); +} + +function hasStartedTooShortPassword(value: string): boolean { + return value.length > 0 && value.length < MIN_PASSWORD_LENGTH; +} + +function hasReusablePassword(currentPassword: string, nextPassword: string) { + return ( + currentPassword.length >= MIN_PASSWORD_LENGTH && + nextPassword.length >= MIN_PASSWORD_LENGTH && + currentPassword === nextPassword + ); +} + +function passwordValidationMessage( + t: T, + currentPassword: string, + nextPassword: string, + confirmPassword: string, +): string { + if (currentPassword.length < MIN_PASSWORD_LENGTH) { + return t("settings.general.passwordDialog.currentTooShort", { + minLength: MIN_PASSWORD_LENGTH, + }); + } + if (nextPassword.length < MIN_PASSWORD_LENGTH) { + return t("settings.general.passwordDialog.newTooShort", { + minLength: MIN_PASSWORD_LENGTH, + }); + } + if (nextPassword !== confirmPassword) { + return t("settings.general.passwordDialog.mismatch"); + } + if (currentPassword === nextPassword) { + return t("settings.general.passwordDialog.samePassword"); + } + return ""; +} + +async function unauthorizedDetail(response: Response): Promise { + if (response.status !== 401) { + return null; + } + const payload = (await response + .clone() + .json() + .catch(() => null)) as { + detail?: string; + } | null; + return payload?.detail ?? null; +} + +function postChangePassword( + currentPassword: string, + nextPassword: string, +): Promise { + const headers = new Headers({ "Content-Type": "application/json" }); + const token = getAuthToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + return fetch(apiUrl("/api/auth/change-password"), { + method: "POST", + headers, + body: changePasswordBody(currentPassword, nextPassword), + }); +} + +async function requestPasswordChange( + currentPassword: string, + nextPassword: string, +): Promise> { + let response = await postChangePassword(currentPassword, nextPassword); + const detail = await unauthorizedDetail(response); + if (response.status === 401 && detail !== WRONG_CURRENT_PASSWORD_DETAIL) { + // Retry token/session 401s, but never turn the endpoint's + // "wrong current password" validation into a session refresh/logout. + if (await refreshSession()) { + response = await postChangePassword(currentPassword, nextPassword); + } + } + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { + detail?: string; + } | null; + throw new Error(payload?.detail || ""); + } + return (await response.json()) as Record; +} + +/** + * Change the signed-in account's password from Settings, reusing the existing + * POST /api/auth/change-password endpoint. The forced first-login flow lives at + * /change-password and bounces non-forced users to /login, so day-to-day changes + * need their own self-contained entry point here. + */ +export function ChangePasswordDialog() { + const t = useT(); + const [open, setOpen] = useState(false); + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const reset = () => { + setCurrent(""); + setNext(""); + setConfirm(""); + }; + + const currentTooShort = hasStartedTooShortPassword(current); + const nextTooShort = hasStartedTooShortPassword(next); + const mismatch = confirm.length > 0 && next !== confirm; + const samePassword = hasReusablePassword(current, next); + const validationMessage = passwordValidationMessage( + t, + current, + next, + confirm, + ); + const disabled = submitting || Boolean(validationMessage); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (validationMessage) { + toast.error(validationMessage); + return; + } + setSubmitting(true); + try { + const data = await requestPasswordChange(current, next); + const accessToken = stringField(data, "access_token"); + const refreshToken = stringField(data, "refresh_token"); + if (!(accessToken && refreshToken)) { + throw new Error(t("settings.general.passwordDialog.updateFailed")); + } + // The endpoint rotates the JWT secret and returns fresh tokens. + storeAuthTokens(accessToken, refreshToken); + setMustChangePassword(booleanField(data, "must_change_password")); + toast.success(t("settings.general.passwordDialog.updated")); + reset(); + setOpen(false); + } catch (err) { + toast.error( + err instanceof Error && err.message + ? err.message + : t("settings.general.passwordDialog.updateFailed"), + ); + } finally { + setSubmitting(false); + } + } + + return ( + { + if (submitting && !o) { + return; + } + setOpen(o); + if (!o) { + reset(); + } + }} + > + + + + { + if (submitting) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + if (submitting) { + event.preventDefault(); + } + }} + > +
+ + + {t("settings.general.passwordDialog.title")} + + + {t("settings.general.passwordDialog.description", { + minLength: MIN_PASSWORD_LENGTH, + })} + + +
+
+ + setCurrent(e.target.value)} + minLength={MIN_PASSWORD_LENGTH} + disabled={submitting} + /> + {currentTooShort ? ( +

+ {t("settings.general.passwordDialog.currentTooShort", { + minLength: MIN_PASSWORD_LENGTH, + })} +

+ ) : null} +
+
+ + setNext(e.target.value)} + minLength={MIN_PASSWORD_LENGTH} + disabled={submitting} + /> + {nextTooShort || samePassword ? ( +

+ {nextTooShort + ? t("settings.general.passwordDialog.newTooShort", { + minLength: MIN_PASSWORD_LENGTH, + }) + : t("settings.general.passwordDialog.samePassword")} +

+ ) : null} +
+
+ + setConfirm(e.target.value)} + minLength={MIN_PASSWORD_LENGTH} + disabled={submitting} + /> + {mismatch ? ( +

+ {t("settings.general.passwordDialog.mismatch")} +

+ ) : null} +
+
+ + + +
+
+
+ ); +} diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 975eeb69f4..6e34b3b23d 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { usePlatformStore } from "@/config/env"; import { isTauri } from "@/lib/api-base"; -import { openModelsDir } from "@/features/native-intents/api"; +import { openModelsDir } from "@/features/native-intents"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { toast } from "@/lib/toast"; import { loadModelsFolder, type ModelsFolder } from "../api/models-folder"; @@ -39,6 +39,7 @@ import { loadUploadLimitSettings, updateUploadLimitSettings, } from "../api/upload-limit"; +import { ChangePasswordDialog } from "../components/change-password-dialog"; import { SettingsRow } from "../components/settings-row"; import { SettingsSection } from "../components/settings-section"; import { StudioVersionSection } from "../components/studio-version-section"; @@ -339,6 +340,17 @@ export function GeneralTab() {
+ {/* The desktop app authenticates via desktop auto-auth with a generated + secret, so there is no user-entered password to change here (and + changing it would clear the desktop secret). Web only. */} + {isTauri ? null : ( + + + + )} {modelsFolder ? ( diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 1ea352f081..ddb8286b73 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -105,6 +105,27 @@ export const en = { "Used to load gated models and push artifacts.", hideToken: "Hide token", showToken: "Show token", + password: "Password", + passwordDescription: "Change the password for this Studio account.", + passwordDialog: { + trigger: "Change password", + title: "Change password", + description: + "Enter your current password and choose a new one (at least {minLength} characters).", + currentPassword: "Current password", + newPassword: "New password", + confirmPassword: "Confirm new password", + currentTooShort: + "Current password must be at least {minLength} characters.", + newTooShort: "New password must be at least {minLength} characters.", + mismatch: "Passwords do not match.", + samePassword: + "New password must be different from your current password.", + update: "Update password", + updating: "Updating...", + updated: "Password updated.", + updateFailed: "Password update failed.", + }, chatDefaults: "Chat defaults", autoTitleNewChats: "Auto-title new chats", autoTitleNewChatsDescription: diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index c87ea1b312..c89b1daa46 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -104,6 +104,24 @@ export const zhCN = { huggingFaceTokenDescription: "用于加载受限模型和推送产物。", hideToken: "隐藏 token", showToken: "显示 token", + password: "密码", + passwordDescription: "更改此 Studio 账号的密码。", + passwordDialog: { + trigger: "更改密码", + title: "更改密码", + description: "输入当前密码并选择新密码(至少 {minLength} 个字符)。", + currentPassword: "当前密码", + newPassword: "新密码", + confirmPassword: "确认新密码", + currentTooShort: "当前密码至少需要 {minLength} 个字符。", + newTooShort: "新密码至少需要 {minLength} 个字符。", + mismatch: "两次输入的密码不一致。", + samePassword: "新密码必须与当前密码不同。", + update: "更新密码", + updating: "正在更新...", + updated: "密码已更新。", + updateFailed: "密码更新失败。", + }, chatDefaults: "聊天默认设置", autoTitleNewChats: "自动为新聊天命名", autoTitleNewChatsDescription: "根据第一条消息生成简短标题。", From 007a21235cde2979bc9c905aa0a8964cf51a61e0 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 08:20:06 -0700 Subject: [PATCH 035/306] Generalize transformers tier selection by probing AutoConfig (#6550) * Resolve the transformers tier by probing AutoConfig instead of guessing When the only signal is a 5.x tokenizer class, get_transformers_tier guessed the lowest 5.x sidecar (530). That misroutes models whose built-in config parser needs a higher tier: dense NemotronH ships a 5.x tokenizer but its '-' (MLP) layer only transformers 5.10 can parse, so 5.3/5.5 raise KeyError '-'. The config.json transformers_version field records the saving version, not the minimum to load, so it cannot drive routing either. Replace the weak tokenizer->530 guesses (local and remote) with a probe: parse config.json with the built-in parser (trust_remote_code=False) in each sidecar, escalating 530->550->510, and pick the first that succeeds. This generalizes to any architecture without hardcoded lists. Strong signals stay fast paths (no subprocess); the probe runs only when the tier is otherwise ambiguous and is cached by (model, commit sha). It never executes repo code, never downloads weights, never raises, and falls back to the legacy 530 guess on a transient/auth/offline failure or when no sidecar is available. UNSLOTH_DISABLE_TIER_PROBE restores the old behavior. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: tier probe fallbacks and cross-platform robustness Codex: - Never escalate to 510 on uncertainty. When every sidecar was probed and none parsed with the built-in parser, the model is a remote-code / custom model_type that loads via its own code; keep the legacy 530 route instead of jumping to 510 (which would change the behavior of models that worked on the 5.3 stack). - Only cache the 530 fallback when the result is conclusive (every tier actually probed). If a sidecar was missing/uninstallable the environment is incomplete, so return 530 uncached and retry on the next call. - Do not pin the tier cache under an unknown revision: _resolve_commit_sha no longer memoizes a None sha (a transient Hub failure is retried), and _probe_tier only caches a tier when the commit sha is known. Gemini: - Wrap Path.exists() in the sha resolver in try/except OSError (a remote repo id can raise WinError 123 on Windows). - Probe script writes the error to sys.stderr.buffer as UTF-8 bytes so a non-ASCII message cannot itself raise UnicodeEncodeError under cp1252. - subprocess.run decodes stderr with errors="replace" to avoid UnicodeDecodeError on non-UTF-8 consoles. Tests: 72 passed (added partial-sidecar uncached, sha-unresolved not cached, all-failed stays 530 + cached, sha resolver retries None / handles OSError). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review round 2: authenticate tier checks, stop memoizing local sigs Codex: - Thread hf_token through _check_config_needs_510/550 and _check_tokenizer_config_needs_v5 (and the underlying raw fetches). Previously a gated/private model whose only 5.x signal is tokenizer_config.json never reached the authenticated probe: the unauthenticated raw fetch failed and cached False, so the model fell through to the default 4.x tier. The per-check caches are now keyed by (model, token) so an unauthenticated miss cannot poison a later authed read, mirroring _load_config_json. - _resolve_commit_sha no longer memoizes a local directory signature. A local signature is mutable (size/mtime of config/tokenizer), so a reused/overwritten checkpoint path would otherwise keep selecting the previous tier; it is now recomputed every call. Only the immutable remote commit sha is memoized. Tests: 75 passed (added token-cache isolation + auth header, local signature not memoized, token threaded into all checks/probe). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review round 3: reach activation with the token, drop SHA tier cache Codex round 3: - Thread hf_token into the activation path that actually selects a sidecar. The token-aware tier checks added last round were unreachable: activate_transformers_for_subprocess called get_transformers_tier without a token, and the inference/training/export workers passed only the model name even though they hold a request-scoped hf_token. activate_transformers_for_subprocess now takes hf_token and the three workers forward config["hf_token"], so a gated/private model whose only 5.x signal is an authenticated config/tokenizer is routed to the right sidecar instead of falling to default 4.x. - Stop importing huggingface_hub during tier detection. _probe_tier no longer resolves a commit sha, so it never pulls huggingface_hub into the worker before the sidecar venv is prepended to sys.path (activation only prepends, never purges), which would otherwise pin the default-env hub over the sidecar's pinned huggingface_hub==1.8.0. - The tier cache is now keyed by model_name for the process lifetime (a model's required tier is a property of its architecture; cleared on restart). This drops the mutable-SHA memo that masked remote revision changes and the mutable local-signature memo, removing _resolve_commit_sha / _local_dir_signature / _probe_sha_cache entirely. - Do not cache a probe success that depended on a skipped lower tier: if a lower sidecar was unavailable, the lowest valid tier may change once it installs, so the result is returned uncached and re-probed next call. Tests: 73 passed (probe imports no hub; success uncached when a lower tier is skipped; activation forwards the token). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim comments to be more succinct * Re-probe overwritten local checkpoints and authenticate the probe child The AutoConfig tier probe cached its result under the bare model_name, so a local checkpoint overwritten in place (same path, new config.json) kept serving the stale sidecar. Fold a cheap config.json signature (size + mtime) into the cache key for local paths; remote ids stay name-keyed so no huggingface_hub import lands before the sidecar is activated. The probe relies on the implicit HF_TOKEN env, so an inherited HF_HUB_DISABLE_IMPLICIT_TOKEN=1 left it unauthenticated and a gated repo 401ed into the 530 fail-safe. Clear that flag in the child env when a token is set. * Keep tier probes off the log-only path and probe new 5.x archs default-first - get_transformers_tier gains probe=True/False. needs_transformers_5 (a coarse 4-vs-5 boolean used only for a spawn log and a vision-check branch) now passes probe=False, so a parent/log-only caller never spawns sidecar probes. The real activation path keeps probe=True and resolves the exact tier in the worker. - A config.json saved by transformers 5.x but matched by no fast path is now probed default-first: _probe_tier gains include_default + floor, prepending the ambient 4.57.x tier to the escalation. A model that still parses on the default is left on it (no mis-route onto a sidecar); only a config the default parser cannot read escalates to the lowest 5.x tier that parses. The transformers_version field is a cheap 'worth probing' hint only, read from the already-fetched config (no extra network); ordinary 4.x configs never probe. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Separate probe cache by mode and keep version-field 5.x visible to needs_transformers_5 - _probe_tier cache was keyed only by config.json signature, so a default-first probe that returned 'default' could be handed back to a later tokenizer/known-5.x caller (floor=530), leaving a model with a 5.x-only tokenizer on transformers 4.x. Key the cache by probe mode (floor + include_default); the legacy 530 mode keeps the bare key. - The version-field 5.x detection is a cheap config read, not a probe, so run it even when probe=False: a standard-tokenizer model whose only signal is transformers_version >= 5 now classifies as 5.x via needs_transformers_5 (returns '530' without spawning a probe), so the vision-routing fallback uses the 5.x subprocess instead of failing the default parser and marking it non-vision. The real activation path still probes default-first and may resolve 'default'. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Don't treat local checkpoints as Hub ids, and fix stale activation test double - _load_config_json / _check_tokenizer_config_needs_v5: a local checkpoint dir whose config.json / tokenizer_config.json is not yet present was being fetched from the Hub as if the path were a repo id, and the 404 miss was cached. A later call after the file is written (in-progress checkpoint) then served the stale miss, so a TokenizersBackend checkpoint fell through to the default tier. Skip the Hub fetch for local dirs and do not cache the miss, so the file is read once it appears. - test_activate_transformers_version_or_warn_*: the worker now threads hf_token into _activate_transformers_version (model_name, hf_token); update the one-arg test doubles to the real two-arg signature so the silent-success path stays silent. * Tighten comments in the AutoConfig probe and tier-selection paths * Address review: canonical probe cache key and reuse _token_cache_key - _probe_cache_key resolves config.json to its absolute realpath before keying, so a relative path or a changed cwd can't collide with or miss a prior probe result. Remote ids still fall back to the name (stat raises, caught). - _cached_config_json reuses _token_cache_key instead of re-hashing the token inline, keeping the (model, token) key derivation in one place. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/export/worker.py | 6 +- studio/backend/core/inference/worker.py | 8 +- studio/backend/core/training/worker.py | 12 +- .../tests/test_mlx_training_worker_config.py | 6 +- studio/backend/tests/test_ssm_runtime.py | 2 +- .../tests/test_transformers_version.py | 510 +++++++++++++++++- studio/backend/utils/transformers_version.py | 444 ++++++++++++--- 7 files changed, 876 insertions(+), 112 deletions(-) diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py index d504aff7ea..fdaa306e10 100644 --- a/studio/backend/core/export/worker.py +++ b/studio/backend/core/export/worker.py @@ -159,7 +159,7 @@ def _setup_log_capture(resp_queue: Any) -> None: t_err.start() -def _activate_transformers_version(model_name: str) -> None: +def _activate_transformers_version(model_name: str, hf_token: str | None = None) -> None: """Activate the correct transformers version BEFORE any ML imports.""" # Ensure backend is on sys.path for utils imports. backend_path = str(Path(__file__).resolve().parent.parent.parent) @@ -168,7 +168,7 @@ def _activate_transformers_version(model_name: str) -> None: from utils.transformers_version import activate_transformers_for_subprocess - activate_transformers_for_subprocess(model_name) + activate_transformers_for_subprocess(model_name, hf_token) def _send_response(resp_queue: Any, response: dict) -> None: @@ -460,7 +460,7 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None # ── 1. Activate correct transformers version BEFORE any ML imports ── try: - _activate_transformers_version(checkpoint_path) + _activate_transformers_version(checkpoint_path, config.get("hf_token") or None) except Exception as exc: _send_response( resp_queue, diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index aa22ce821f..4e27183d88 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -36,13 +36,13 @@ def _ensure_backend_on_path() -> None: sys.path.insert(0, _BACKEND_PATH) -def _activate_transformers_version(model_name: str) -> None: +def _activate_transformers_version(model_name: str, hf_token: str | None = None) -> None: """Activate the correct transformers version BEFORE any ML imports.""" _ensure_backend_on_path() from utils.transformers_version import activate_transformers_for_subprocess - activate_transformers_for_subprocess(model_name) + activate_transformers_for_subprocess(model_name, hf_token) def _decode_image(image_base64: str): @@ -673,7 +673,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf # Non-fatal: fall through with the installed version, but log the cause # instead of swallowing it (issue #6103). try: - _activate_transformers_version(model_name) + _activate_transformers_version(model_name, config.get("hf_token") or None) except Exception as exc: logger.warning( "Failed to activate transformers version for '%s' (MLX inference); " @@ -783,7 +783,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf # ── 1. Activate transformers version (on the resolved base) BEFORE any ML imports ── try: - _activate_transformers_version(_base) + _activate_transformers_version(_base, _hf_token) except Exception as exc: _send_response( resp_queue, diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 70c3bc9a28..3f020c8abc 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -1049,7 +1049,7 @@ def _ensure_flash_attn_for_long_context(event_queue: Any, max_seq_length: int) - _send_status(event_queue, "Continuing without flash-attn") -def _activate_transformers_version(model_name: str) -> None: +def _activate_transformers_version(model_name: str, hf_token: str | None = None) -> None: """Activate the correct transformers version BEFORE any ML imports.""" # Ensure backend is on path for utils imports backend_path = str(Path(__file__).resolve().parent.parent.parent) @@ -1058,10 +1058,10 @@ def _activate_transformers_version(model_name: str) -> None: from utils.transformers_version import activate_transformers_for_subprocess - activate_transformers_for_subprocess(model_name) + activate_transformers_for_subprocess(model_name, hf_token) -def _activate_transformers_version_or_warn(model_name: str) -> None: +def _activate_transformers_version_or_warn(model_name: str, hf_token: str | None = None) -> None: """Activate the required transformers version for the MLX fast-path. Unlike the non-MLX path (which treats activation failure as fatal and @@ -1072,7 +1072,7 @@ def _activate_transformers_version_or_warn(model_name: str) -> None: is visible, while keeping the fall-through behaviour. """ try: - _activate_transformers_version(model_name) + _activate_transformers_version(model_name, hf_token) except Exception as exc: logger.warning( "Failed to activate transformers version for '%s' (MLX); " @@ -2139,7 +2139,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> # Must happen before any transformers/mlx-lm imports in _run_mlx_training. # Non-fatal: fall through with whatever version is installed, but log # the failure instead of swallowing it (issue #6103). - _activate_transformers_version_or_warn(model_name) + _activate_transformers_version_or_warn(model_name, config.get("hf_token") or None) try: _run_mlx_training(event_queue, stop_queue, config) except Exception as exc: @@ -2155,7 +2155,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> # ── 1. Activate correct transformers version BEFORE any ML imports ── try: - _activate_transformers_version(model_name) + _activate_transformers_version(model_name, config.get("hf_token") or None) except Exception as exc: event_queue.put( { diff --git a/studio/backend/tests/test_mlx_training_worker_config.py b/studio/backend/tests/test_mlx_training_worker_config.py index e55815f512..dce5e27c08 100644 --- a/studio/backend/tests/test_mlx_training_worker_config.py +++ b/studio/backend/tests/test_mlx_training_worker_config.py @@ -250,7 +250,7 @@ def test_activate_transformers_version_or_warn_logs_on_failure(monkeypatch): ) monkeypatch.setattr(_worker, "logger", fake_logger) - def _boom(_name): + def _boom(_name, _hf_token = None): raise RuntimeError("venv .venv_t5_550 missing") monkeypatch.setattr(_worker, "_activate_transformers_version", _boom) @@ -268,7 +268,9 @@ def test_activate_transformers_version_or_warn_silent_on_success(monkeypatch): warning = lambda *a, **k: warnings_logged.append((a, k)), ) monkeypatch.setattr(_worker, "logger", fake_logger) - monkeypatch.setattr(_worker, "_activate_transformers_version", lambda _name: None) + monkeypatch.setattr( + _worker, "_activate_transformers_version", lambda _name, _hf_token = None: None + ) _worker._activate_transformers_version_or_warn("meta-llama/Llama-3-8B") diff --git a/studio/backend/tests/test_ssm_runtime.py b/studio/backend/tests/test_ssm_runtime.py index dbf482c8bc..2f6bae9b79 100644 --- a/studio/backend/tests/test_ssm_runtime.py +++ b/studio/backend/tests/test_ssm_runtime.py @@ -424,7 +424,7 @@ def test_inference_worker_resolves_remote_lora_base_pre_import(): def test_inference_worker_tiers_on_base_and_gates_lora_base_only(): src = (_BACKEND / "core" / "inference" / "worker.py").read_text() # Tier activation runs on the resolved base, not the raw adapter id (remote-LoRA fix). - assert "_activate_transformers_version(_base)" in src + assert "_activate_transformers_version(_base" in src # The gate only adds a genuine LoRA base, never a full fine-tune's recorded (unloaded) base. assert "_gate_targets" in src and "_lora_base" in src diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 5736c9104d..989c6378bd 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -52,6 +52,9 @@ from utils.transformers_version import ( _config_needs_510_cache, _config_needs_530_cache, _config_needs_550_cache, + _probe_tier_cache, + _probe_tier, + _stderr_is_transient, needs_transformers_5, get_transformers_tier, activate_transformers_for_subprocess, @@ -312,11 +315,45 @@ class TestCheckTokenizerConfigNeedsV5: tc = {"tokenizer_class": "TokenizersBackend"} (tmp_path / "tokenizer_config.json").write_text(json.dumps(tc)) - key = str(tmp_path) - _check_tokenizer_config_needs_v5(key) + key = (str(tmp_path), None) + _check_tokenizer_config_needs_v5(str(tmp_path)) assert key in _tokenizer_class_cache assert _tokenizer_class_cache[key] is True + def test_token_cache_isolation_and_auth_fetch(self, monkeypatch): + # A gated repo: the unauthenticated miss (cached under (model, None)) must not block a + # later authed fetch (separate key), and the token rides in the Authorization header. + import utils.transformers_version as tv + + monkeypatch.setattr(tv, "_env_offline", lambda: False) + seen_auth = [] + + class _Resp: + def __init__(self, body): + self._b = body + + def read(self): + return self._b.encode() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_urlopen(req, timeout = 10): + auth = req.get_header("Authorization") + seen_auth.append(auth) + if auth: + return _Resp(json.dumps({"tokenizer_class": "TokenizersBackend"})) + raise OSError("HTTP 401") + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + assert _check_tokenizer_config_needs_v5("org/gated") is False # unauth miss + assert _check_tokenizer_config_needs_v5("org/gated", "tok") is True # authed hit + assert seen_auth == [None, "Bearer tok"] + assert _tokenizer_class_cache[("org/gated", None)] is False # miss not poisoning + # --------------------------------------------------------------------------- # needs_transformers_5 — integration-level @@ -406,8 +443,8 @@ class TestCheckConfigNeeds550: cfg = {"architectures": ["Gemma4ForConditionalGeneration"]} (tmp_path / "config.json").write_text(json.dumps(cfg)) - key = str(tmp_path) - _check_config_needs_550(key) + key = (str(tmp_path), None) + _check_config_needs_550(str(tmp_path)) assert key in _config_needs_550_cache assert _config_needs_550_cache[key] is True @@ -506,8 +543,8 @@ class TestCheckConfigNeeds510: cfg = {"architectures": ["Gemma4UnifiedForConditionalGeneration"]} (tmp_path / "config.json").write_text(json.dumps(cfg)) - key = str(tmp_path) - _check_config_needs_510(key) + key = (str(tmp_path), None) + _check_config_needs_510(str(tmp_path)) assert key in _config_needs_510_cache assert _config_needs_510_cache[key] is True @@ -750,11 +787,11 @@ class TestTierCheckTransientRetry: # Network blip -> serve the cache, but do NOT pin the tier result. with patch("urllib.request.urlopen", side_effect = OSError("boom")): assert _check_config_needs_510("org/model") is False - assert "org/model" not in _config_needs_510_cache + assert ("org/model", None) not in _config_needs_510_cache # Connectivity returns: the next call re-fetches and sees the higher tier. with patch("urllib.request.urlopen", return_value = _hf_response(fresh)): assert _check_config_needs_510("org/model") is True - assert _config_needs_510_cache["org/model"] is True # definitive read is memoized + assert _config_needs_510_cache[("org/model", None)] is True # definitive read memoized def test_definitive_network_read_is_memoized(self, tmp_path: Path, monkeypatch): fresh = {"architectures": ["Gemma4ForConditionalGeneration"]} # needs 550 @@ -1037,6 +1074,442 @@ class TestGetTransformersTier: assert needs_transformers_5("meta-llama/Llama-3-8B") is False +def _proc(returncode, stderr = ""): + from types import SimpleNamespace + return SimpleNamespace(returncode = returncode, stdout = "", stderr = stderr) + + +class TestProbeTier: + """_probe_tier resolves the tier by parsing config in each sidecar and escalating.""" + + def setup_method(self): + _probe_tier_cache.clear() + + def _patch_common(self, monkeypatch): + for fn in ( + "_ensure_venv_t5_530_exists", + "_ensure_venv_t5_550_exists", + "_ensure_venv_t5_510_exists", + ): + monkeypatch.setattr(f"utils.transformers_version.{fn}", lambda: True) + monkeypatch.delenv("UNSLOTH_DISABLE_TIER_PROBE", raising = False) + + def _venv_dirs(self): + import utils.transformers_version as tv + return [tv._VENV_T5_530_DIR, tv._VENV_T5_550_DIR, tv._VENV_T5_510_DIR] + + def test_escalates_to_first_parsing_tier(self, monkeypatch): + self._patch_common(monkeypatch) + seen = [] + results = iter([_proc(1, "KeyError: '-'"), _proc(1, "KeyError: '-'"), _proc(0)]) + + def fake_run(cmd, **k): + seen.append(cmd[3]) # target_dir + return next(results) + + monkeypatch.setattr("utils.transformers_version.subprocess.run", fake_run) + assert _probe_tier("org/dense-nemotron", None, "x") == "510" + assert seen == self._venv_dirs() # escalated 530 -> 550 -> 510 + + def test_first_success_stops_escalation(self, monkeypatch): + self._patch_common(monkeypatch) + calls = [] + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: calls.append(cmd[3]) or _proc(0), + ) + assert _probe_tier("org/m", None, "x") == "530" + assert len(calls) == 1 + + def test_middle_tier_parses(self, monkeypatch): + self._patch_common(monkeypatch) + results = iter([_proc(1, "ValueError: bad"), _proc(0)]) + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", lambda cmd, **k: next(results) + ) + assert _probe_tier("org/m", None, "x") == "550" + + def test_nothing_parses_stays_530_and_caches(self, monkeypatch): + # All tiers probed, none parse -> a remote-code model that loads via its own code; + # keep 530 (never jump to 510). Conclusive, so cached by model_name. + self._patch_common(monkeypatch) + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: _proc(1, "KeyError: '-'"), + ) + assert _probe_tier("org/m", None, "x") == "530" + assert _probe_tier_cache["org/m"] == "530" + + def test_partial_sidecars_no_parse_is_530_uncached(self, monkeypatch): + # 510 sidecar missing and 530/550 fail to parse -> environment is incomplete, so we + # cannot conclude; return 530 uncached so it is retried once 510 is available. + monkeypatch.delenv("UNSLOTH_DISABLE_TIER_PROBE", raising = False) + for fn in ("_ensure_venv_t5_530_exists", "_ensure_venv_t5_550_exists"): + monkeypatch.setattr(f"utils.transformers_version.{fn}", lambda: True) + monkeypatch.setattr("utils.transformers_version._ensure_venv_t5_510_exists", lambda: False) + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: _proc(1, "KeyError: '-'"), + ) + assert _probe_tier("org/m", None, "x") == "530" + assert "org/m" not in _probe_tier_cache + + def test_success_not_cached_when_lower_tier_skipped(self, monkeypatch): + # 530 sidecar unavailable but 550 parses: return 550 (best effort now) but do NOT + # cache it, since once 530 is installed it may be the lowest valid tier. + monkeypatch.delenv("UNSLOTH_DISABLE_TIER_PROBE", raising = False) + monkeypatch.setattr("utils.transformers_version._ensure_venv_t5_530_exists", lambda: False) + for fn in ("_ensure_venv_t5_550_exists", "_ensure_venv_t5_510_exists"): + monkeypatch.setattr(f"utils.transformers_version.{fn}", lambda: True) + monkeypatch.setattr("utils.transformers_version.subprocess.run", lambda cmd, **k: _proc(0)) + assert _probe_tier("org/m", None, "x") == "550" + assert "org/m" not in _probe_tier_cache # skipped a lower tier -> not pinned + + def test_cache_hit_skips_subprocess(self, monkeypatch): + self._patch_common(monkeypatch) + monkeypatch.setattr("utils.transformers_version.subprocess.run", lambda cmd, **k: _proc(0)) + assert _probe_tier("org/m", None, "x") == "530" + + def boom(cmd, **k): + raise AssertionError("should not re-probe a cached model_name") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + assert _probe_tier("org/m", None, "x") == "530" + + def test_transient_failure_is_530_and_uncached(self, monkeypatch): + self._patch_common(monkeypatch) + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: _proc(1, "ConnectionError: Max retries exceeded"), + ) + assert _probe_tier("org/m", None, "x") == "530" + assert "org/m" not in _probe_tier_cache # retried next load + + def test_timeout_is_530_and_uncached(self, monkeypatch): + import subprocess as _sp + + self._patch_common(monkeypatch) + + def timeout(cmd, **k): + raise _sp.TimeoutExpired(cmd, 60) + + monkeypatch.setattr("utils.transformers_version.subprocess.run", timeout) + assert _probe_tier("org/m", None, "x") == "530" + assert "org/m" not in _probe_tier_cache + + def test_all_venvs_missing_is_530_no_spawn(self, monkeypatch): + for fn in ( + "_ensure_venv_t5_530_exists", + "_ensure_venv_t5_550_exists", + "_ensure_venv_t5_510_exists", + ): + monkeypatch.setattr(f"utils.transformers_version.{fn}", lambda: False) + + def boom(cmd, **k): + raise AssertionError("no sidecar available; must not spawn") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + assert _probe_tier("org/m", None, "x") == "530" + assert "org/m" not in _probe_tier_cache # nothing probed -> uncached + + def test_probe_does_not_import_hub(self, monkeypatch): + # The probe must not import huggingface_hub: that would land before the sidecar is on + # sys.path (activation never purges), pinning the default-env hub. So no in-process sha. + self._patch_common(monkeypatch) + monkeypatch.setattr("utils.transformers_version.subprocess.run", lambda cmd, **k: _proc(0)) + sys.modules.pop("huggingface_hub", None) + _probe_tier("org/m", None, "x") + assert "huggingface_hub" not in sys.modules + + def test_disable_flag_skips_probe(self, monkeypatch): + monkeypatch.setenv("UNSLOTH_DISABLE_TIER_PROBE", "1") + + def boom(cmd, **k): + raise AssertionError("probe disabled; must not spawn") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + assert _probe_tier("org/m", None, "x") == "530" + + def test_get_tier_uses_probe_for_remote_tokenizer_signal(self, monkeypatch): + # tokenizer says 5.x but no architecture/substring match -> probe (not a 530 guess). + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_510", lambda m, t = None: False + ) + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_550", lambda m, t = None: False + ) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", lambda m, t = None: True + ) + monkeypatch.setattr("utils.transformers_version._probe_tier", lambda m, t, reason: "510") + assert get_transformers_tier("org/unknown-5x-arch") == "510" + + def test_stderr_is_transient(self): + assert _stderr_is_transient("ConnectionError: x") is True + assert _stderr_is_transient("GatedRepoError: need token") is True + assert _stderr_is_transient("KeyError: '-'") is False + assert _stderr_is_transient("ValueError: bad pattern") is False + + def test_get_tier_threads_token_to_checks(self, monkeypatch): + # A gated/private model: the token must reach the config/tokenizer checks (and the + # probe), otherwise the authed-only signal is missed and it falls to default 4.x. + seen = {} + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_510", + lambda m, t = None: seen.update({"510": t}) or False, + ) + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_550", + lambda m, t = None: seen.update({"550": t}) or False, + ) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", + lambda m, t = None: seen.update({"tok": t}) or True, + ) + monkeypatch.setattr( + "utils.transformers_version._probe_tier", + lambda m, t, reason: seen.update({"probe": t}) or "510", + ) + assert get_transformers_tier("org/gated-5x", "hf_abc") == "510" + assert seen == {"510": "hf_abc", "550": "hf_abc", "tok": "hf_abc", "probe": "hf_abc"} + + def test_activate_threads_token_to_tier(self, monkeypatch): + # activate_transformers_for_subprocess must forward hf_token to tier detection, or + # the gated-model checks above run unauthenticated and the fix is unreachable. + seen = {} + monkeypatch.setattr("utils.transformers_version._resolve_base_model", lambda m: m) + monkeypatch.setattr( + "utils.transformers_version.get_transformers_tier", + lambda m, t = None: seen.update({"model": m, "token": t}) or "default", + ) + activate_transformers_for_subprocess("org/gated", "hf_xyz") + assert seen == {"model": "org/gated", "token": "hf_xyz"} + + def test_local_checkpoint_reprobes_after_config_change(self, monkeypatch, tmp_path): + # A local checkpoint overwritten in place must re-probe: the cache key folds in the + # config.json signature, so a different config does not serve the stale tier. + self._patch_common(monkeypatch) + cfg = tmp_path / "config.json" + cfg.write_text(json.dumps({"model_type": "a"})) + local = str(tmp_path) + calls = [] + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: calls.append(1) or _proc(0), + ) + assert _probe_tier(local, None, "x") == "530" + assert _probe_tier(local, None, "x") == "530" # cache hit, no re-spawn + assert len(calls) == 1 + cfg.write_text(json.dumps({"model_type": "a_longer_value_changing_the_size"})) + assert _probe_tier(local, None, "x") == "530" + assert len(calls) == 2 # signature changed -> re-probed + + def test_probe_child_enables_implicit_token(self, monkeypatch): + # With a token, the probe child must clear an inherited HF_HUB_DISABLE_IMPLICIT_TOKEN=1 + # so HF_TOKEN authenticates the gated config fetch instead of 401ing to 530. + self._patch_common(monkeypatch) + monkeypatch.setenv("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1") + captured = {} + + def fake_run(cmd, **k): + captured.update(k.get("env") or {}) + return _proc(0) + + monkeypatch.setattr("utils.transformers_version.subprocess.run", fake_run) + assert _probe_tier("org/gated", "secret-token", "x") == "530" + assert captured.get("HF_TOKEN") == "secret-token" + assert captured.get("HF_HUB_DISABLE_IMPLICIT_TOKEN") == "0" + + +class TestProbeGating: + """probe=False suppresses sidecar probes (the log-only needs_transformers_5 path); a + config saved by transformers 5.x is probed default-first so a new 5.x-only arch is + caught without mis-routing a 4.57.x-loadable model onto a sidecar.""" + + def setup_method(self): + _probe_tier_cache.clear() + _config_json_cache.clear() + _config_needs_510_cache.clear() + _config_needs_550_cache.clear() + _tokenizer_class_cache.clear() + + def _patch_venvs(self, monkeypatch): + for fn in ( + "_ensure_venv_t5_530_exists", + "_ensure_venv_t5_550_exists", + "_ensure_venv_t5_510_exists", + ): + monkeypatch.setattr(f"utils.transformers_version.{fn}", lambda: True) + monkeypatch.delenv("UNSLOTH_DISABLE_TIER_PROBE", raising = False) + + def _patch_checks_to_tokenizer(self, monkeypatch): + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_510", lambda m, t = None: False + ) + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_550", lambda m, t = None: False + ) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", lambda m, t = None: True + ) + + # ---- needs_transformers_5 / probe=False must not spawn probes -------------- + + def test_needs_transformers_5_does_not_spawn_probe(self, monkeypatch): + self._patch_checks_to_tokenizer(monkeypatch) + + def boom(cmd, **k): + raise AssertionError("needs_transformers_5 must not spawn a probe") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + # Still correctly reports 5.x from the tokenizer signal, just without probing. + assert needs_transformers_5("org/unknown-5x") is True + + def test_probe_false_returns_530_for_tokenizer_signal(self, monkeypatch): + self._patch_checks_to_tokenizer(monkeypatch) + + def boom(cmd, **k): + raise AssertionError("probe=False must not spawn a probe") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + assert get_transformers_tier("org/unknown-5x", probe = False) == "530" + + # ---- version-field probe is default-first (no mis-routing of 4.x models) ---- + + def test_version_field_probe_stays_default_when_default_parses(self, monkeypatch): + self._patch_venvs(monkeypatch) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", lambda m, t = None: False + ) + _config_json_cache[("org/new", None)] = { + "model_type": "brandnew", + "transformers_version": "5.0.0", + } + seen = [] + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: seen.append(cmd[3]) or _proc(0), + ) + assert get_transformers_tier("org/new") == "default" + assert seen == [""] # probed the ambient default tier first, it parsed -> stayed default + + def test_version_field_probe_escalates_when_default_fails(self, monkeypatch): + import utils.transformers_version as tv + + self._patch_venvs(monkeypatch) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", lambda m, t = None: False + ) + _config_json_cache[("org/new", None)] = { + "model_type": "brandnew", + "transformers_version": "5.6.0", + } + results = iter([_proc(1, "KeyError: 'x'"), _proc(1, "KeyError: 'x'"), _proc(0)]) + seen = [] + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: seen.append(cmd[3]) or next(results), + ) + assert get_transformers_tier("org/new") == "550" + assert seen == ["", tv._VENV_T5_530_DIR, tv._VENV_T5_550_DIR] + + def test_ordinary_4x_config_does_not_probe(self, monkeypatch): + self._patch_venvs(monkeypatch) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", lambda m, t = None: False + ) + _config_json_cache[("org/llama", None)] = { + "model_type": "llama", + "transformers_version": "4.57.0", + } + + def boom(cmd, **k): + raise AssertionError("a 4.x-saved config must not trigger a probe") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + assert get_transformers_tier("org/llama") == "default" + + def test_needs_transformers_5_true_for_version_field_only(self, monkeypatch): + # A 5.x-saved standard-tokenizer model must report as 5.x (for vision routing) + # without spawning a probe. + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_510", lambda m, t = None: False + ) + monkeypatch.setattr( + "utils.transformers_version._check_config_needs_550", lambda m, t = None: False + ) + monkeypatch.setattr( + "utils.transformers_version._check_tokenizer_config_needs_v5", lambda m, t = None: False + ) + _config_json_cache[("org/new", None)] = { + "model_type": "brandnew", + "transformers_version": "5.2.0", + } + + def boom(cmd, **k): + raise AssertionError("needs_transformers_5 must not spawn a probe") + + monkeypatch.setattr("utils.transformers_version.subprocess.run", boom) + assert needs_transformers_5("org/new") is True + + def test_default_first_result_not_reused_for_tokenizer_path(self, monkeypatch, tmp_path): + # A default-first probe can cache "default"; a later tokenizer/known-5.x call + # (floor=530) must re-probe, not reuse that "default". + self._patch_venvs(monkeypatch) + (tmp_path / "config.json").write_text( + json.dumps({"model_type": "brandnew", "transformers_version": "5.0.0"}) + ) + local = str(tmp_path) + monkeypatch.setattr("utils.transformers_version.subprocess.run", lambda cmd, **k: _proc(0)) + assert ( + _probe_tier(local, None, "version", include_default = True, floor = "default") == "default" + ) + seen = [] + monkeypatch.setattr( + "utils.transformers_version.subprocess.run", + lambda cmd, **k: seen.append(cmd[3]) or _proc(0), + ) + # Tokenizer/known-5.x mode (floor=530): must re-probe and never reuse "default". + assert _probe_tier(local, None, "tokenizer needs 5.x") == "530" + assert seen, "tokenizer path reused the cached default result instead of re-probing" + + +class TestLocalCheckpointFilesAppear: + """A local checkpoint dir inspected before its files exist must not cache the miss or hit + the network, so files written later in the same process are still read (in-progress + checkpoints).""" + + def setup_method(self): + _tokenizer_class_cache.clear() + _config_json_cache.clear() + + def test_tokenizer_config_appearing_later_is_read(self, tmp_path: Path, monkeypatch): + local = str(tmp_path) + + def boom(*a, **k): + raise AssertionError("a local checkpoint must not be fetched from the Hub") + + monkeypatch.setattr("urllib.request.urlopen", boom) + # Before the file exists: not 5.x, no network, and the miss must not be pinned. + assert _check_tokenizer_config_needs_v5(local) is False + # The file appears with a 5.x-only tokenizer -> the next call must read it. + (tmp_path / "tokenizer_config.json").write_text( + json.dumps({"tokenizer_class": "TokenizersBackend"}) + ) + assert _check_tokenizer_config_needs_v5(local) is True + + def test_config_json_appearing_later_is_read(self, tmp_path: Path, monkeypatch): + local = str(tmp_path) + + def boom(*a, **k): + raise AssertionError("a local checkpoint must not be fetched from the Hub") + + monkeypatch.setattr("urllib.request.urlopen", boom) + assert _load_config_json(local) is None + (tmp_path / "config.json").write_text(json.dumps({"model_type": "gemma4"})) + assert _load_config_json(local) == {"model_type": "gemma4"} + + # --------------------------------------------------------------------------- # activate_transformers_for_subprocess — issue #6103 # The early log must make clear it only prepends to sys.path; the real @@ -1130,7 +1603,7 @@ class TestActivateLoggingClarity: ), patch( "utils.transformers_version.get_transformers_tier", - side_effect = lambda m: tiers[m], + side_effect = lambda m, t = None: tiers[m], ), patch( "utils.transformers_version._ensure_venv_t5_510_exists", @@ -1157,7 +1630,7 @@ class TestActivateLoggingClarity: snap = self._snapshot_env() seen = [] - def fake_tier(m): + def fake_tier(m, t = None): seen.append(m) return "550" if "gemma-4" in m else "default" @@ -1618,14 +2091,15 @@ class TestCheckConfigNeeds530: with patch("utils.transformers_version._load_config_json", return_value = None): assert _check_config_needs_530("org/unreachable-model") is False - def test_result_is_cached(self): - with patch( - "utils.transformers_version._load_config_json", - return_value = {"model_type": "qwen3_5"}, - ) as mock_load: - _check_config_needs_530("cached-model") - _check_config_needs_530("cached-model") - assert mock_load.call_count == 1 + def test_result_is_cached(self, tmp_path: Path): + """A definitive (local) read is cached by (model, token), mirroring 510/550.""" + cfg = {"model_type": "qwen3_5"} + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + key = (str(tmp_path), None) + _check_config_needs_530(str(tmp_path)) + assert key in _config_needs_530_cache + assert _config_needs_530_cache[key] is True # --------------------------------------------------------------------------- diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 959b9e99cd..1d9ba88aa6 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -149,14 +149,19 @@ _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = { "TokenizersBackend", } -# Cache for dynamic tokenizer_config.json lookups (avoids repeated fetches). -_tokenizer_class_cache: dict[str, bool] = {} - -# config.json cache keyed on (model_name, token-hash) so authed/unauthed reads stay separate. +# Caches keyed on (model_name, token-hash) so authed/unauthed reads stay separate (a +# gated/private repo's unauthenticated miss must not poison a later authenticated lookup). +_tokenizer_class_cache: dict[tuple[str, str | None], bool] = {} _config_json_cache: dict[tuple[str, str | None], dict | None] = {} -_config_needs_510_cache: dict[str, bool] = {} -_config_needs_550_cache: dict[str, bool] = {} -_config_needs_530_cache: dict[str, bool] = {} +_config_needs_510_cache: dict[tuple[str, str | None], bool] = {} +_config_needs_550_cache: dict[tuple[str, str | None], bool] = {} +_config_needs_530_cache: dict[tuple[str, str | None], bool] = {} + +# AutoConfig-probe tier cache for the process lifetime (cleared on restart), keyed by +# model_name plus a local config.json signature (see _probe_cache_key) so an overwritten +# checkpoint re-probes. Not keyed by Hub sha, so the probe never imports huggingface_hub +# before a worker's sidecar venv is activated (which would pin the wrong hub). +_probe_tier_cache: dict[str, str] = {} # Versions TRANSFORMERS_510_VERSION = "5.10.2" @@ -185,25 +190,29 @@ def _higher_tier(a: str, b: str) -> str: return a if _TIER_RANK.get(a, 0) >= _TIER_RANK.get(b, 0) else b -def activate_transformers_for_subprocess(model_name: str) -> None: +def activate_transformers_for_subprocess(model_name: str, hf_token: str | None = None) -> None: """Activate the correct transformers version in a subprocess worker. Call BEFORE any ML imports. Resolves LoRA adapters to their base model, determines the required tier, prepends the appropriate ``.venv_t5_*`` dir to ``sys.path``, and propagates it via ``PYTHONPATH`` for child processes (e.g. GGUF converter). Used by training, inference, and export workers. + + ``hf_token`` is forwarded to tier detection so a gated/private model whose only 5.x + signal is an authenticated config/tokenizer reaches the right sidecar, not the default. """ - # Pre-resolve only LoRA adapters; full checkpoints go to get_transformers_tier - # so their local config.json drives the tier (avoids a fragile HF-id probe). + # Pre-resolve only LoRA adapters; full checkpoints go to get_transformers_tier so their + # local config.json drives the tier (a full checkpoint with a private/offline + # _name_or_path must not resolve to an unreachable HF id and skip its own config). if _is_lora_adapter_dir(Path(model_name)): resolved = _resolve_base_model(model_name) else: resolved = model_name - tier = get_transformers_tier(resolved) - if model_name != resolved and (Path(model_name) / "config.json").is_file(): + tier = get_transformers_tier(resolved, hf_token) + if model_name != resolved and _safe_is_file(Path(model_name) / "config.json"): # Gate on a real local config.json: a checkpoint carries config the base may not # surface, but path names alone must not upgrade a plain adapter. - tier = _higher_tier(tier, get_transformers_tier(model_name)) + tier = _higher_tier(tier, get_transformers_tier(model_name, hf_token)) if tier == "510": if not _ensure_venv_t5_510_exists(): @@ -372,6 +381,15 @@ def _resolve_base_model(model_name: str) -> str: return model_name +def _token_cache_key(model_name: str, hf_token: str | None) -> tuple[str, str | None]: + """Cache key that keeps authenticated and unauthenticated reads separate, so an + unauthenticated miss on a gated/private repo never poisons a later authed lookup.""" + import hashlib + + tok = hashlib.sha256(hf_token.encode()).hexdigest()[:16] if hf_token else None + return (model_name, tok) + + def _is_canonical_repo_id(model_name: str) -> bool: """True for a canonical ``owner/repo`` Hub id (not a local or relative path).""" return bool( @@ -469,15 +487,18 @@ def _remote_lora_base(model_name: str, hf_token: str | None = None) -> str | Non return _adapter_base_from_hf_cache(model_name) -def _check_tokenizer_config_needs_v5(model_name: str) -> bool: +def _check_tokenizer_config_needs_v5(model_name: str, hf_token: str | None = None) -> bool: """True if the model's tokenizer_class requires transformers 5.x. - Checks local tokenizer_config.json, else fetches from HuggingFace. Cached in - ``_tokenizer_class_cache``. Returns False on any network/parse error + Checks local tokenizer_config.json, else fetches from HuggingFace (authenticated + with ``hf_token`` so gated/private repos resolve). Cached in + ``_tokenizer_class_cache``, keyed by (model, token) so an unauthenticated miss does + not poison a later authed read. Returns False on any network/parse error (fail-open to default version). """ - if model_name in _tokenizer_class_cache: - return _tokenizer_class_cache[model_name] + cache_key = _token_cache_key(model_name, hf_token) + if cache_key in _tokenizer_class_cache: + return _tokenizer_class_cache[cache_key] # --- Check local tokenizer_config.json first --------------------------- local_path = Path(model_name) @@ -494,22 +515,30 @@ def _check_tokenizer_config_needs_v5(model_name: str) -> bool: model_name, tokenizer_class, ) - _tokenizer_class_cache[model_name] = result + _tokenizer_class_cache[cache_key] = result return result except Exception as exc: logger.debug("Could not read %s: %s", local_tc, exc) + # Local checkpoint without the file yet: don't fetch it as a Hub id or cache the miss, + # so a file written later this process (in-progress checkpoint) is read next call. + if _safe_is_dir(local_path): + return False + # Offline: skip the 10s urllib fetch (fail-open to lower tier). if _env_offline(): - _tokenizer_class_cache[model_name] = False + _tokenizer_class_cache[cache_key] = False return False # --- Fall back to fetching from HuggingFace ---------------------------- import urllib.request url = f"https://huggingface.co/{model_name}/raw/main/tokenizer_config.json" + headers = {"User-Agent": "unsloth-studio"} + if hf_token: + headers["Authorization"] = f"Bearer {hf_token}" try: - req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-studio"}) + req = urllib.request.Request(url, headers = headers) with urllib.request.urlopen(req, timeout = 10) as resp: data = json.loads(resp.read().decode()) tokenizer_class = data.get("tokenizer_class", "") @@ -520,11 +549,11 @@ def _check_tokenizer_config_needs_v5(model_name: str) -> bool: model_name, tokenizer_class, ) - _tokenizer_class_cache[model_name] = result + _tokenizer_class_cache[cache_key] = result return result except Exception as exc: logger.debug("Could not fetch tokenizer_config.json for '%s': %s", model_name, exc) - _tokenizer_class_cache[model_name] = False + _tokenizer_class_cache[cache_key] = False return False @@ -598,6 +627,11 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No _config_json_cache[cache_key] = None return None + # Local checkpoint without the file yet: don't fetch it as a Hub id or cache the miss, + # so a file written later this process (in-progress checkpoint) is read next call. + if _safe_is_dir(Path(model_name)): + return None + if _env_offline(): # No network: a previously downloaded repo can still tier from the hub cache. cfg = _config_json_from_hf_cache(model_name) @@ -631,10 +665,10 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No return _config_json_from_hf_cache(model_name) -def _config_json_is_definitive(model_name: str) -> bool: - """True if the last unauthenticated ``_load_config_json`` read was cached (definitive), - not a transient fallback (deliberately not stored, so callers re-check next call).""" - return (model_name, None) in _config_json_cache +def _config_json_is_definitive(model_name: str, hf_token: str | None = None) -> bool: + """True if the last ``_load_config_json`` read for this model+token was cached + (definitive), not a transient fallback (not stored, so callers re-check next call).""" + return _token_cache_key(model_name, hf_token) in _config_json_cache def _config_matches_tier(cfg: dict, architectures: set[str], model_types: set[str]) -> bool: @@ -694,14 +728,16 @@ def _config_needs_530(cfg: dict) -> bool: ) -def _check_config_needs_550(model_name: str) -> bool: +def _check_config_needs_550(model_name: str, hf_token: str | None = None) -> bool: """True if ``config.json`` needs transformers 5.5.0 (e.g. Gemma 4). Local first, else - fetched; cached only for a definitive read so a transient miss retries. False on error. + fetched (authenticated with ``hf_token``); cached by (model, token) only for a definitive + read so a transient miss retries. False on error. """ - if model_name in _config_needs_550_cache: - return _config_needs_550_cache[model_name] + cache_key = _token_cache_key(model_name, hf_token) + if cache_key in _config_needs_550_cache: + return _config_needs_550_cache[cache_key] - cfg = _load_config_json(model_name) + cfg = _load_config_json(model_name, hf_token) result = bool(cfg) and _config_needs_550(cfg) if result: logger.info( @@ -711,26 +747,22 @@ def _check_config_needs_550(model_name: str) -> bool: cfg.get("architectures", []), cfg.get("model_type"), ) - if _config_json_is_definitive(model_name): - _config_needs_550_cache[model_name] = result + if _config_json_is_definitive(model_name, hf_token): + _config_needs_550_cache[cache_key] = result return result -def _check_config_needs_530(model_name: str) -> bool: - """Check ``config.json`` for 5.3.0-only architectures (Qwen3.5, Qwen3 MoE, GLM-4.7, LFM2.5-VL). - - Used in the slow HF-ID path for private/renamed repos where name substrings - aren't reliable. +def _check_config_needs_530(model_name: str, hf_token: str | None = None) -> bool: + """True if ``config.json`` needs transformers 5.3.0 (Qwen3.5, Qwen3 MoE, GLM-4.7, LFM2.5-VL). + Local first, else fetched (authenticated with ``hf_token``); cached by (model, token) only + for a definitive read so a transient miss retries. False on error. """ - if model_name in _config_needs_530_cache: - return _config_needs_530_cache[model_name] + cache_key = _token_cache_key(model_name, hf_token) + if cache_key in _config_needs_530_cache: + return _config_needs_530_cache[cache_key] - cfg = _load_config_json(model_name) - if cfg is None: - _config_needs_530_cache[model_name] = False - return False - - result = _config_needs_530(cfg) + cfg = _load_config_json(model_name, hf_token) + result = bool(cfg) and _config_needs_530(cfg) if result: logger.info( "config.json check: %s needs transformers %s (architectures=%s, model_type=%s)", @@ -739,16 +771,19 @@ def _check_config_needs_530(model_name: str) -> bool: cfg.get("architectures", []), cfg.get("model_type"), ) - _config_needs_530_cache[model_name] = result + if _config_json_is_definitive(model_name, hf_token): + _config_needs_530_cache[cache_key] = result return result -def _check_config_needs_510(model_name: str) -> bool: - """Check ``config.json`` for Gemma 4 Unified / 12B architectures.""" - if model_name in _config_needs_510_cache: - return _config_needs_510_cache[model_name] +def _check_config_needs_510(model_name: str, hf_token: str | None = None) -> bool: + """Check ``config.json`` for Gemma 4 Unified / 12B architectures (authenticated with + ``hf_token``; cached by (model, token) only for a definitive read).""" + cache_key = _token_cache_key(model_name, hf_token) + if cache_key in _config_needs_510_cache: + return _config_needs_510_cache[cache_key] - cfg = _load_config_json(model_name) + cfg = _load_config_json(model_name, hf_token) result = bool(cfg) and _config_needs_510(cfg) if result: logger.info( @@ -758,11 +793,227 @@ def _check_config_needs_510(model_name: str) -> bool: cfg.get("architectures", []), cfg.get("model_type"), ) - if _config_json_is_definitive(model_name): - _config_needs_510_cache[model_name] = result + if _config_json_is_definitive(model_name, hf_token): + _config_needs_510_cache[cache_key] = result return result +def _config_saved_by_transformers_5(cfg: dict | None) -> bool: + """True if ``config.json``'s ``transformers_version`` is >= 5. Only a cheap "worth + probing" hint (the saving version, not the minimum to load); the default-first probe + decides the actual tier.""" + if not isinstance(cfg, dict): + return False + ver = cfg.get("transformers_version") + if not isinstance(ver, str): + return False + try: + return int(ver.strip().split(".", 1)[0]) >= 5 + except ValueError: + return False + + +def _cached_config_json(model_name: str, hf_token: str | None) -> dict | None: + """Already-fetched config.json from the in-process cache (no new fetch); the tier checks + above populate it, and a miss just skips the version-field probe.""" + return _config_json_cache.get(_token_cache_key(model_name, hf_token)) + + +# --- AutoConfig probe: general tier resolution for ambiguous models ---------- +# When the cheap signals only say "needs some 5.x", parse config.json with the built-in +# parser in each candidate sidecar (lowest first) instead of guessing. Generalizes beyond +# the hardcoded lists, e.g. dense NemotronH whose '-' (MLP) layer only 5.10 can parse. +_PROBE_TIER_ORDER = ("530", "550", "510") +_PROBE_TIMEOUT_SECS = 60 + +# config.json-only parse in a sidecar (--target dir on sys.path, no per-venv python). +# Built-in parser only, no repo code, no weights. Exit 0 = parses; token via env, not argv. +_PROBE_CONFIG_SCRIPT = r""" +import sys, os +os.environ["TOKENIZERS_PARALLELISM"] = "false" +target_dir, model_name = sys.argv[1], sys.argv[2] +if target_dir: # empty = probe the ambient (default 4.57.x) transformers, no sidecar prepend + sys.path.insert(0, target_dir) +try: + from transformers import AutoConfig + AutoConfig.from_pretrained(model_name, trust_remote_code=False) + sys.exit(0) +except Exception as exc: + # stderr encoding may not be UTF-8 (e.g. cp1252 on Windows); write bytes so a + # non-ASCII error message cannot itself raise UnicodeEncodeError. + sys.stderr.buffer.write((type(exc).__name__ + ": " + str(exc)).encode("utf-8", "replace")) + sys.exit(1) +""" + +# stderr fragments meaning "couldn't fetch/auth", NOT "needs a newer parser". +_PROBE_TRANSIENT_MARKERS = ( + "ConnectionError", + "HTTPError", + "Timeout", + "Max retries", + "Temporary failure", + "GatedRepoError", + "RepositoryNotFoundError", + "LocalEntryNotFoundError", + "OfflineModeIsEnabled", + "401", + "403", + "404", +) + + +def _stderr_is_transient(err: str) -> bool: + return any(marker in err for marker in _PROBE_TRANSIENT_MARKERS) + + +def _probe_tier_venvs(): + """tier -> (target_dir, ensure_fn), a function so the later _ensure_* defs resolve. The + ``default`` entry (empty target_dir = ambient 4.57.x) is only probed with include_default.""" + return { + "default": ("", lambda: True), + "530": (_VENV_T5_530_DIR, _ensure_venv_t5_530_exists), + "550": (_VENV_T5_550_DIR, _ensure_venv_t5_550_exists), + "510": (_VENV_T5_510_DIR, _ensure_venv_t5_510_exists), + } + + +def _probe_autoconfig(target_dir: str, model_name: str, hf_token: str | None) -> bool | None: + """Parse config.json with the built-in parser inside *target_dir*'s sidecar. + True = parses, False = parse/version failure (escalate), None = transient + (auth/network/offline/spawn) so the caller fails safe and does not cache. + """ + env = child_env_without_native_path_secret() + if hf_token: + env["HF_TOKEN"] = hf_token + # The probe relies on the implicit HF_TOKEN env (no token= arg). Clear any inherited + # HF_HUB_DISABLE_IMPLICIT_TOKEN=1 so a gated repo authenticates instead of 401ing + # into the 530 fail-safe. + env["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "0" + if _env_offline(): + env["HF_HUB_OFFLINE"] = "1" + env["TRANSFORMERS_OFFLINE"] = "1" + try: + result = subprocess.run( + [sys.executable, "-c", _PROBE_CONFIG_SCRIPT, target_dir, model_name], + capture_output = True, + text = True, + errors = "replace", + timeout = _PROBE_TIMEOUT_SECS, + env = env, + **_windows_hidden_subprocess_kwargs(), + ) + except subprocess.TimeoutExpired: + logger.warning("AutoConfig probe timed out for '%s' in %s", model_name, target_dir) + return None + except Exception as exc: + logger.warning("AutoConfig probe could not spawn for '%s': %s", model_name, exc) + return None + if result.returncode == 0: + return True + err = (result.stderr or "").strip() + if _stderr_is_transient(err): + logger.warning("AutoConfig probe transient failure for '%s': %s", model_name, err) + return None + logger.info("AutoConfig probe parse failure for '%s' in %s: %s", model_name, target_dir, err) + return False + + +def _probe_cache_key(model_name: str) -> str: + """Cache key for the probe result. A local checkpoint can be overwritten in place, so + fold in a cheap config.json signature (size + mtime) and re-probe when it changes. + Remote ids key by name alone (resolving a Hub revision would need a pre-activation hub + import that pins the wrong env).""" + try: + config_path = (Path(model_name) / "config.json").resolve() + st = config_path.stat() + except OSError: + return model_name + return f"{config_path}\0{st.st_size}:{st.st_mtime_ns}" + + +def _probe_tier( + model_name: str, + hf_token: str | None, + reason: str, + *, + include_default: bool = False, + floor: str = "530", +) -> str: + """Lowest tier whose built-in parser loads the config; *floor* is the fail-safe. + + Escalates ``_PROBE_TIER_ORDER`` (prefixed with the ambient ``default`` tier when + ``include_default``), returning the first that parses; never raises or escalates on + uncertainty: + - first success wins (cached unless a lower tier was skipped); + - transient failure (auth/network/offline) -> *floor*, uncached; + - a skipped/uninstallable sidecar -> uncached (a lower tier may yet be the answer); + - all tiers probed, none parse -> remote-code/custom model_type; keep *floor*. + + Known-5.x callers use ``floor='530'``; weak-signal callers (config saved by transformers + 5.x) use ``include_default=True, floor='default'`` so a model that still parses on 4.57.x + stays on the default. Cached per _probe_cache_key (process lifetime). No Hub sha is + resolved: that would import huggingface_hub before the sidecar is on sys.path. + """ + if os.environ.get("UNSLOTH_DISABLE_TIER_PROBE", "").lower() in ("1", "true", "yes"): + return floor + key = _probe_cache_key(model_name) + # Key by probe mode: the default-first path can return 'default', which must not be + # reused for a tokenizer/known-5.x caller (floor='530'). Legacy 530 keeps the bare key. + if include_default or floor != "530": + key = f"{key}\0floor={floor}:def={int(include_default)}" + if key in _probe_tier_cache: + return _probe_tier_cache[key] + + def _cache(tier: str, *, skipped: bool) -> str: + # Do not pin a result that depended on a skipped lower tier: once that sidecar is + # available the lowest valid tier may differ, so re-probe next call. + if not skipped: + _probe_tier_cache[key] = tier + return tier + + venvs = _probe_tier_venvs() + order = (("default",) + _PROBE_TIER_ORDER) if include_default else _PROBE_TIER_ORDER + probed_count = 0 + skipped_any = False + for tier in order: + target_dir, ensure_fn = venvs[tier] + try: + available = ensure_fn() + except Exception: + available = False + if not available: + skipped_any = True + continue + probed_count += 1 + ok = _probe_autoconfig(target_dir, model_name, hf_token) + if ok is True: + logger.info( + "Transformers tier %s selected for %s (AutoConfig probe; %s)", + tier, + model_name, + reason, + ) + return _cache(tier, skipped = skipped_any) + if ok is None: + logger.info("Tier probe inconclusive for %s (%s); using %s", model_name, reason, floor) + return floor # transient: retry next load + + # Nothing parsed. Only treat it as conclusive (and cache) when every tier was actually + # probed; a skipped sidecar means the environment is incomplete, so retry uncached. + if skipped_any or probed_count == 0: + logger.info( + "Tier probe incomplete for %s (%s); using %s (uncached)", model_name, reason, floor + ) + return floor + logger.info( + "Transformers tier %s selected for %s (AutoConfig probe found no higher tier; %s)", + floor, + model_name, + reason, + ) + return _cache(floor, skipped = False) + + def _norm_separators(s: str) -> str: """Collapse ``_``/whitespace to ``-`` (underscore aliases) but keep ``.`` so a version dot (``qwen3.5``) isn't conflated with a size separator (``Qwen3-5B``).""" @@ -816,7 +1067,11 @@ def _higher_tier_name_override(name_hint: str | None) -> str | None: return hint[0] if hint is not None and hint[0] in ("510", "550") else None -def get_transformers_tier(model_name: str) -> str: +def get_transformers_tier( + model_name: str, + hf_token: str | None = None, + probe: bool = True, +) -> str: """Return the transformers tier required for *model_name*. Returns ``"510"`` for models needing transformers 5.10.x (Gemma 4 Unified), @@ -824,14 +1079,24 @@ def get_transformers_tier(model_name: str) -> str: ``"530"`` for models needing transformers 5.3.0 (e.g. Ministral-3, Qwen3 MoE), or ``"default"`` for everything else (4.57.x). - Higher 5.x tiers run first. For local paths, ``config.json`` is checked - before name heuristics to avoid false-positives from directory name fragments. + Strong signals (architecture/model_type, name substrings) are fast paths. For local paths, + ``config.json`` is checked before name heuristics to avoid false-positives from directory + name fragments. When the only signal is the 5.x tokenizer class, the exact tier is resolved + by probing AutoConfig in each sidecar; a config saved by transformers 5.x with no fast-path + match is probed default-first, catching a new 5.x-only arch while 4.57.x-loadable models + stay on default. + + ``probe=False`` skips the sidecar subprocesses (used by the cheap + :func:`needs_transformers_5`); it still classifies via cheap signals (a 5.x-saved config + returns ``"530"``). ``probe=True`` (the activation path) resolves the exact tier. + + Higher 5.x tiers run first. """ # Local path: trust config.json. If its arch matches a known sidecar, return; # else fall back to the HF id in the config (not the folder name) for renamed dirs. local_cfg = Path(model_name) / "config.json" if _safe_is_file(local_cfg): - cfg = _load_config_json(model_name) + cfg = _load_config_json(model_name, hf_token) if cfg is not None: if _config_needs_510(cfg): logger.info( @@ -873,7 +1138,7 @@ def get_transformers_tier(model_name: str) -> str: resolved = _resolve_base_model(model_name) if resolved != model_name: if _safe_is_dir(Path(resolved)): - tier = get_transformers_tier(resolved) + tier = get_transformers_tier(resolved, hf_token, probe = probe) if tier != "default": logger.info( "Transformers tier %s selected for %s (resolved local path: %s)", @@ -895,12 +1160,22 @@ def get_transformers_tier(model_name: str) -> str: ) return tier local_tc = Path(model_name) / "tokenizer_config.json" - if _safe_is_file(local_tc) and _check_tokenizer_config_needs_v5(model_name): - logger.info( - "Transformers tier 530 selected for %s (local tokenizer_config.json check)", + if _safe_is_file(local_tc) and _check_tokenizer_config_needs_v5(model_name, hf_token): + if not probe: + return "530" + return _probe_tier(model_name, hf_token, "local tokenizer needs 5.x") + if _config_saved_by_transformers_5(cfg): + if not probe: + return "530" # cheap 5.x hint; the real path resolves the exact tier + tier = _probe_tier( model_name, + hf_token, + "local config saved by transformers 5.x", + include_default = True, + floor = "default", ) - return "530" + if tier != "default": + return tier logger.info( "Transformers tier default (4.57.x) selected for %s (local config.json no match)", model_name, @@ -919,17 +1194,17 @@ def get_transformers_tier(model_name: str) -> str: ) return tier - # --- Slow config fallbacks (network for HF IDs) ------------------------ - if _check_config_needs_510(model_name): + # --- Slow config fallbacks (network for HF IDs; authenticated with hf_token) -------- + if _check_config_needs_510(model_name, hf_token): logger.info("Transformers tier 510 selected for %s (config.json check)", model_name) return "510" - if _check_config_needs_550(model_name): + if _check_config_needs_550(model_name, hf_token): logger.info("Transformers tier 550 selected for %s (config.json check)", model_name) return "550" - if _check_config_needs_530(model_name): - # Same Qwen3.6 caveat as the local path: honor a _name_or_path name hint - # before selecting 530. - remote_cfg = _load_config_json(model_name) or {} + if _check_config_needs_530(model_name, hf_token): + # Qwen3.6 reuses Qwen3.5 config ids but needs 5.5 by name; honor a real Hub-id name + # hint from _name_or_path before selecting 530. + remote_cfg = _load_config_json(model_name, hf_token) or {} base = remote_cfg.get("_name_or_path") or remote_cfg.get("model_name") override = _higher_tier_name_override( base if isinstance(base, str) and base != model_name else None @@ -943,12 +1218,23 @@ def get_transformers_tier(model_name: str) -> str: return override logger.info("Transformers tier 530 selected for %s (config.json check)", model_name) return "530" - if _check_tokenizer_config_needs_v5(model_name): - logger.info( - "Transformers tier 530 selected for %s (tokenizer_config.json check)", + if _check_tokenizer_config_needs_v5(model_name, hf_token): + if not probe: + return "530" + return _probe_tier(model_name, hf_token, "tokenizer needs 5.x") + + if _config_saved_by_transformers_5(_cached_config_json(model_name, hf_token)): + if not probe: + return "530" # cheap 5.x hint; the real path resolves the exact tier + tier = _probe_tier( model_name, + hf_token, + "config saved by transformers 5.x", + include_default = True, + floor = "default", ) - return "530" + if tier != "default": + return tier logger.info("Transformers tier default (4.57.x) selected for %s (no match)", model_name) return "default" @@ -957,9 +1243,11 @@ def get_transformers_tier(model_name: str) -> str: def needs_transformers_5(model_name: str) -> bool: """Return True if *model_name* requires any transformers 5.x version. - Convenience wrapper around :func:`get_transformers_tier`. + Convenience wrapper around :func:`get_transformers_tier`. Passes ``probe=False`` so a + log-only parent caller never spawns sidecar probes (the worker re-resolves the exact + tier with ``probe=True`` on the real activation path). """ - return get_transformers_tier(model_name) != "default" + return get_transformers_tier(model_name, probe = False) != "default" # --------------------------------------------------------------------------- @@ -1236,7 +1524,7 @@ def ensure_transformers_version(model_name: str) -> None: else: resolved = model_name tier = get_transformers_tier(resolved) - if model_name != resolved and (Path(model_name) / "config.json").is_file(): + if model_name != resolved and _safe_is_file(Path(model_name) / "config.json"): # Gate on a real local config.json: a checkpoint carries config the base may not # surface, but path names alone must not upgrade a plain adapter. tier = _higher_tier(tier, get_transformers_tier(model_name)) From 7bd8e64921c5cff9fad53295009ab9620ba0c1af Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:21:59 -0700 Subject: [PATCH 036/306] Studio: honor custom HF_HOME for model download and load (#6510) * Studio: honor custom HF_HOME for model download and load _setup_cache_env always derived HF_HUB_CACHE and HF_XET_CACHE from XDG_CACHE_HOME / ~/.cache, ignoring a user-set HF_HOME. Because it sets HF_HUB_CACHE explicitly and that variable takes precedence over HF_HOME in huggingface_hub, the hub cache was pinned to the standard location: a model already present under a custom HF_HOME was detected but then re-downloaded from scratch on load. Seed HF_HUB_CACHE and HF_XET_CACHE from HF_HOME when the user set it (HF's own default is $HF_HOME/hub and $HF_HOME/xet), and honor the legacy HUGGINGFACE_HUB_CACHE alias. The hub download workers call snapshot_download without a cache_dir for both the Xet and HTTP-fallback paths, so they follow HF_HUB_CACHE; fixing it here unifies detection and both transports on one root. Explicit HF_HUB_CACHE / HF_XET_CACHE stay untouched. Adds tests for the custom-HF_HOME, default, explicit-override, and legacy-alias cases. Fixes #5182. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: do not crash startup when a custom HF_HOME is not writable Seeding HF_HUB_CACHE/HF_XET_CACHE from HF_HOME means _setup_cache_env now mkdir's under a user-controlled path. A non-writable or not-yet-mounted HF_HOME (typo, offline drive) would raise and crash startup, where the old code silently fell back. Make the mkdir best-effort; the env var is still set, so HF reports a clear error at download time. Adds a regression test. * Studio: strip blank HF_HOME and isolate cache-env tests Address review: a whitespace-only HF_HOME no longer derives " /hub"; strip it and fall back to the default (matches studio_root). Tests set UNSLOTH_STUDIO_HOME to a tmp dir so _setup_cache_env's UV/VLLM mkdirs do not touch the real ~/.unsloth/studio. Adds a whitespace regression test. * [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> --- .../tests/test_setup_cache_env_hf_home.py | 125 ++++++++++++++++++ studio/backend/utils/paths/storage_roots.py | 27 +++- 2 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 studio/backend/tests/test_setup_cache_env_hf_home.py diff --git a/studio/backend/tests/test_setup_cache_env_hf_home.py b/studio/backend/tests/test_setup_cache_env_hf_home.py new file mode 100644 index 0000000000..4520c93a51 --- /dev/null +++ b/studio/backend/tests/test_setup_cache_env_hf_home.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""_setup_cache_env() must seed HF_HUB_CACHE / HF_XET_CACHE from a user-set +HF_HOME, so models download to and load from the same custom location (issue +#5182). Both the Xet and HTTP-fallback download workers call snapshot_download +without a cache_dir, so they follow HF_HUB_CACHE; getting it right here fixes +detection and both transports at once. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +_STORAGE_ROOTS_PATH = Path(__file__).resolve().parent.parent / "utils/paths/storage_roots.py" + + +@pytest.fixture(autouse = True) +def _isolate_studio_home(monkeypatch, tmp_path): + # Keep _setup_cache_env's UV/VLLM mkdirs out of the real ~/.unsloth/studio. + monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "studio")) + + +def _load_storage_roots(): + spec = importlib.util.spec_from_file_location("storage_roots_under_test", _STORAGE_ROOTS_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _clear_hf_env(monkeypatch): + for key in ("HF_HOME", "HF_HUB_CACHE", "HF_XET_CACHE", "HUGGINGFACE_HUB_CACHE"): + monkeypatch.delenv(key, raising = False) + + +def test_custom_hf_home_seeds_hub_and_xet(monkeypatch, tmp_path): + sr = _load_storage_roots() + _clear_hf_env(monkeypatch) + custom = tmp_path / "shared" / "huggingface" + monkeypatch.setenv("HF_HOME", str(custom)) + + sr._setup_cache_env() + + import os + + assert os.environ["HF_HUB_CACHE"] == str(custom / "hub") + assert os.environ["HF_XET_CACHE"] == str(custom / "xet") + + +def test_default_when_hf_home_unset(monkeypatch, tmp_path): + sr = _load_storage_roots() + _clear_hf_env(monkeypatch) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg")) + + sr._setup_cache_env() + + import os + + expected = tmp_path / "xdg" / "huggingface" + assert os.environ["HF_HUB_CACHE"] == str(expected / "hub") + + +def test_explicit_hub_cache_is_not_overridden(monkeypatch, tmp_path): + sr = _load_storage_roots() + _clear_hf_env(monkeypatch) + monkeypatch.setenv("HF_HOME", str(tmp_path / "home")) + explicit = tmp_path / "explicit" / "hub" + monkeypatch.setenv("HF_HUB_CACHE", str(explicit)) + + sr._setup_cache_env() + + import os + + assert os.environ["HF_HUB_CACHE"] == str(explicit) + + +def test_legacy_huggingface_hub_cache_alias_is_honored(monkeypatch, tmp_path): + sr = _load_storage_roots() + _clear_hf_env(monkeypatch) + monkeypatch.setenv("HF_HOME", str(tmp_path / "home")) + legacy = tmp_path / "legacy" / "hub" + monkeypatch.setenv("HUGGINGFACE_HUB_CACHE", str(legacy)) + + sr._setup_cache_env() + + import os + + assert os.environ["HF_HUB_CACHE"] == str(legacy) + + +def test_whitespace_hf_home_falls_back_to_default(monkeypatch, tmp_path): + # A blank/whitespace HF_HOME must not become " /hub"; fall back to default. + sr = _load_storage_roots() + _clear_hf_env(monkeypatch) + monkeypatch.setenv("HF_HOME", " ") + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg")) + + sr._setup_cache_env() + + import os + + assert os.environ["HF_HUB_CACHE"] == str(tmp_path / "xdg" / "huggingface" / "hub") + + +def test_unwritable_hf_home_does_not_crash(monkeypatch, tmp_path): + # HF_HOME under a regular file -> mkdir fails; startup must not crash and the + # env var is still set (HF surfaces a clear error later, at download time). + blocker = tmp_path / "blocker" + blocker.write_text("not a dir") + unwritable = blocker / "hf" + sr = _load_storage_roots() + _clear_hf_env(monkeypatch) + monkeypatch.setenv("HF_HOME", str(unwritable)) + + sr._setup_cache_env() # must not raise + + import os + + assert os.environ["HF_HUB_CACHE"] == str(unwritable / "hub") diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index c718f38ffb..759681da3f 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -274,22 +274,37 @@ def _setup_cache_env() -> None: Respects the standard HF cache chain (explicit HF_HOME / HF_HUB_CACHE, then XDG_CACHE_HOME, then ~/.cache/huggingface) and only sets vars the - user hasn't, so explicit overrides are honored. + user hasn't, so explicit overrides are honored. A user-set HF_HOME also + seeds HF_HUB_CACHE / HF_XET_CACHE (HF defaults them to $HF_HOME/hub and + $HF_HOME/xet); without this, models download to and load from the standard + cache even when HF_HOME points elsewhere, and both the Xet and HTTP-fallback + download paths inherit the same wrong root. """ root = cache_root() xdg_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")).expanduser() - hf_default = xdg_cache / "huggingface" + # HUGGINGFACE_HUB_CACHE is HF's legacy alias for HF_HUB_CACHE; honor it. + if "HF_HUB_CACHE" not in os.environ and os.environ.get("HUGGINGFACE_HUB_CACHE"): + os.environ["HF_HUB_CACHE"] = os.environ["HUGGINGFACE_HUB_CACHE"] + # Seed the hub/xet caches from HF_HOME when set, else the platform default. + # Strip so a blank/whitespace HF_HOME falls back instead of making " /hub". + hf_home = (os.environ.get("HF_HOME") or "").strip() + hf_base = Path(hf_home).expanduser() if hf_home else xdg_cache / "huggingface" defaults: dict[str, str] = { - "HF_HOME": str(hf_default), - "HF_HUB_CACHE": str(hf_default / "hub"), - "HF_XET_CACHE": str(hf_default / "xet"), + "HF_HOME": str(hf_base), + "HF_HUB_CACHE": str(hf_base / "hub"), + "HF_XET_CACHE": str(hf_base / "xet"), "UV_CACHE_DIR": str(root / "uv"), "VLLM_CACHE_ROOT": str(root / "vllm"), } for key, value in defaults.items(): if key not in os.environ: os.environ[key] = value - Path(value).mkdir(parents = True, exist_ok = True) + # Best-effort: a non-writable custom HF_HOME must not crash startup; + # HF surfaces a clear error at download time instead. + try: + Path(value).mkdir(parents = True, exist_ok = True) + except OSError: + pass def ensure_studio_directories() -> None: From 3a9fc34fcf4aa62a77fa3183bb00e5fa345ac60e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 08:27:18 -0700 Subject: [PATCH 037/306] Studio Playwright: snooze update banner before sending (#6576) * Studio Playwright: snooze update banner before sending The llama.cpp update banner is a fixed bottom-right toast (z-9998). When an update is available it overlaps the composer's Send button and its subtree intercepts the click, so send_and_wait times out (flaky; surfaces on the Windows studio UI smoke, passes otherwise). Snooze the banner if it is showing before each send, then wait for it to detach. * Also snooze the web update banner before sending The web update banner (web-update-banner, z-9999) is a fixed bottom-right toast like the llama.cpp one and can overlap the Send button too. Loop over both banners and snooze whichever is showing. --- tests/studio/playwright_chat_ui.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index 54e9ca2f46..f9147054ee 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -569,6 +569,21 @@ with sync_playwright() as p: # this at temp 0), and the old non-empty predicate got stuck # on such bubbles. bubbles_before = _bubble_count() + # The llama.cpp and web update banners are fixed bottom-right toasts + # (z-9998 / z-9999) that can overlap the composer's Send button and + # intercept the click. Snooze whichever is showing before sending. + for prefix in ("llama", "web"): + snooze_btn = page.locator(f'[data-testid="{prefix}-update-snooze-button"]') + if snooze_btn.count(): + try: + snooze_btn.first.click(timeout = 2_000) + page.wait_for_selector( + f'[data-testid="{prefix}-update-banner"]', + state = "detached", + timeout = 5_000, + ) + except Exception: + pass composer.click() composer.fill(prompt) page.locator('button[aria-label="Send message"]').click() 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 038/306] 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 039/306] 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 040/306] 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 041/306] 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 042/306] 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 043/306] 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 044/306] 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 045/306] 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 046/306] 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 047/306] 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 (
+ + + diff --git a/studio/backend/main.py b/studio/backend/main.py index a56bd46c4b..a8e81b68e6 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -282,6 +282,7 @@ from routes import ( training_router, ) from routes.llama import router as llama_router +from routes.preview import router as preview_router from hub.routes import ( inventory_router as hub_inventory_router, datasets_router as hub_datasets_router, @@ -672,6 +673,7 @@ from utils.upload_limits import ( # noqa: E402 _BODY_PROTECTED_PREFIXES = ( "/v1/chat/completions", "/v1/completions", + "/p/", "/api/inference", "/api/data-recipe", "/api/datasets", @@ -885,6 +887,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = [" # OpenAI-compatible: mount the inference router at /v1 for external tools. app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) +app.include_router(preview_router, prefix = "/p", tags = ["preview"]) app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"]) app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"]) app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"]) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 670d5f911d..8b6fb36471 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -601,6 +601,8 @@ class TrainingRunSummary(BaseModel): loss_sparkline: Optional[List[float]] = None can_resume: bool = False resumed_later: bool = False + has_preview_model: bool = False + preview_ref: Optional[str] = None class TrainingRunUpdateRequest(BaseModel): diff --git a/studio/backend/routes/preview.py b/studio/backend/routes/preview.py new file mode 100644 index 0000000000..d5247a2bcf --- /dev/null +++ b/studio/backend/routes/preview.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Per-checkpoint preview endpoints: /p/{run}[/{checkpoint}]/v1/...""" + +from __future__ import annotations + +import asyncio +import html +from pathlib import Path +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse +from loggers import get_logger + +from auth.authentication import get_current_subject +from auth.storage import DEFAULT_ADMIN_USERNAME +from models.inference import ChatCompletionRequest, LoadRequest +from routes.inference import load_model, openai_chat_completions +from state.tool_policy import tools_force_disabled +from utils.models.checkpoints import list_preview_targets, resolve_preview_checkpoint + +logger = get_logger(__name__) + +router = APIRouter() + +# Public (no key); resolve_preview_checkpoint pins `run` under outputs_root. +# One model loads at a time, so serialize load+generate across previews. +_preview_lock = asyncio.Lock() + + +def _resolve_or_4xx(run: str, checkpoint: str | None): + try: + return resolve_preview_checkpoint(run, checkpoint) + except ValueError as exc: + # Detail can carry the absolute install path on a symlink escape; log it, + # return a generic message on this public route. + logger.warning("preview path rejected: %s", exc) + raise HTTPException(status_code = 400, detail = "Invalid run or checkpoint") + except FileNotFoundError as exc: + raise HTTPException(status_code = 404, detail = str(exc)) + + +def _sanitize_preview_payload( + payload: ChatCompletionRequest, is_lora: bool +) -> ChatCompletionRequest: + # Public surface: strip tools/MCP + provider routing (no host code / open proxy). + # Normalize use_adapter (never trust the caller): pin True for LoRA, None for + # merged. _apply_adapter_state mutates the shared model without restoring, so an + # unpinned `false` would persist to later visitors who omit the field. + return payload.model_copy( + update = { + "tools": None, + "enable_tools": False, + "enabled_tools": None, + "mcp_enabled": False, + "bypass_permissions": False, + "confirm_tool_calls": False, + "session_id": None, + "rag_scope": None, + "openai_code_exec_container_id": None, + "anthropic_code_exec_container_id": None, + "provider_id": None, + "provider_type": None, + "external_model": None, + "encrypted_api_key": None, + "provider_base_url": None, + "use_adapter": True if is_lora else None, + } + ) + + +async def _unlock_after(body_iterator): + # Hold the lock until the stream drains so another checkpoint can't swap mid-stream. + try: + async for chunk in body_iterator: + yield chunk + finally: + _preview_lock.release() + + +async def _serve_chat( + run: str, checkpoint: str | None, payload: ChatCompletionRequest, request: Request +): + path = _resolve_or_4xx(run, checkpoint) + is_lora = (path / "adapter_config.json").exists() + payload = _sanitize_preview_payload(payload, is_lora) + await _preview_lock.acquire() + keep_locked = False + try: + await load_model(LoadRequest(model_path = str(path)), request, DEFAULT_ADMIN_USERNAME) + # Beats a process-wide `--enable-tools` (enable_tools=False alone wouldn't). + with tools_force_disabled(): + response = await openai_chat_completions(payload, request, DEFAULT_ADMIN_USERNAME) + if isinstance(response, StreamingResponse): + response.body_iterator = _unlock_after(response.body_iterator) + keep_locked = True + return response + finally: + if not keep_locked: + _preview_lock.release() + + +@router.get("") +async def list_previews(request: Request, current_subject: str = Depends(get_current_subject)): + base = str(request.base_url) + previews = [] + for target in list_preview_targets(): + ref = quote(target["ref"], safe = "/") + previews.append({**target, "url": f"{base}p/{ref}/v1"}) + return {"object": "list", "data": previews} + + +@router.post("/{run}/v1/chat/completions") +async def preview_chat_latest(run: str, payload: ChatCompletionRequest, request: Request): + return await _serve_chat(run, None, payload, request) + + +@router.post("/{run}/{checkpoint}/v1/chat/completions") +async def preview_chat_checkpoint( + run: str, checkpoint: str, payload: ChatCompletionRequest, request: Request +): + return await _serve_chat(run, checkpoint, payload, request) + + +def _models_response(run: str, checkpoint: str | None): + path = _resolve_or_4xx(run, checkpoint) + model_id = run if not checkpoint else f"{run}/{checkpoint}" + return { + "object": "list", + "data": [ + { + "id": model_id, + "object": "model", + "created": int(path.stat().st_mtime), + "owned_by": "unsloth-studio", + } + ], + } + + +@router.get("/{run}/v1/models") +async def preview_models_latest(run: str): + return _models_response(run, None) + + +@router.get("/{run}/{checkpoint}/v1/models") +async def preview_models_checkpoint(run: str, checkpoint: str): + return _models_response(run, checkpoint) + + +# Serve logo/fonts here too: the SPA static mount is absent in --api-only (Tauri). +_FRONTEND_DIST = (Path(__file__).resolve().parents[2] / "frontend" / "dist").resolve() +_PREVIEW_ASSET_MEDIA_TYPES = { + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + + +@router.get("/_assets/{asset_path:path}") +async def preview_asset(asset_path: str): + target = (_FRONTEND_DIST / asset_path).resolve() + media_type = _PREVIEW_ASSET_MEDIA_TYPES.get(target.suffix.lower()) + if media_type is None or not target.is_relative_to(_FRONTEND_DIST) or not target.is_file(): + raise HTTPException(status_code = 404, detail = "Not found") + return FileResponse(target, media_type = media_type) + + +# Self-contained public page; only the title is interpolated. +_PREVIEW_PAGE_HTML = ( + Path(__file__).resolve().parent.parent / "assets" / "preview_page.html" +).read_text(encoding = "utf-8") + +_PREVIEW_PAGE_CSP = ( + "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + "img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'" +) + + +def _preview_page(run: str, checkpoint: str | None) -> HTMLResponse: + _resolve_or_4xx(run, checkpoint) + title = run if not checkpoint else f"{run}/{checkpoint}" + page = _PREVIEW_PAGE_HTML.replace("__TITLE__", html.escape(title)) + return HTMLResponse(page, headers = {"Content-Security-Policy": _PREVIEW_PAGE_CSP}) + + +@router.get("/{run}", response_class = HTMLResponse) +async def preview_page_latest(run: str): + return _preview_page(run, None) + + +@router.get("/{run}/{checkpoint}", response_class = HTMLResponse) +async def preview_page_checkpoint(run: str, checkpoint: str): + return _preview_page(run, checkpoint) diff --git a/studio/backend/routes/training_history.py b/studio/backend/routes/training_history.py index 1560c72767..a64d2a938e 100644 --- a/studio/backend/routes/training_history.py +++ b/studio/backend/routes/training_history.py @@ -27,6 +27,7 @@ from storage.studio_db import ( list_runs, update_run_display_name, ) +from utils.models.checkpoints import has_preview_model, preview_ref logger = get_logger(__name__) @@ -42,7 +43,17 @@ async def list_training_runs( """List training runs, newest first.""" result = list_runs(limit = limit, offset = offset) return TrainingRunListResponse( - runs = [TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)}) for r in result["runs"]], + runs = [ + TrainingRunSummary( + **{ + **r, + "can_resume": can_resume_run(r), + "has_preview_model": has_preview_model(r.get("output_dir")), + "preview_ref": preview_ref(r.get("output_dir")), + } + ) + for r in result["runs"] + ], total = result["total"], ) @@ -67,6 +78,8 @@ async def get_training_run_detail(run_id: str, current_subject: str = Depends(ge **{ **{k: v for k, v in run.items() if k != "config_json"}, "can_resume": can_resume_run(run), + "has_preview_model": has_preview_model(run.get("output_dir")), + "preview_ref": preview_ref(run.get("output_dir")), } ), config = config, @@ -98,6 +111,8 @@ async def update_training_run( **{ **{k: v for k, v in refreshed.items() if k != "config_json"}, "can_resume": can_resume_run(refreshed), + "has_preview_model": has_preview_model(refreshed.get("output_dir")), + "preview_ref": preview_ref(refreshed.get("output_dir")), } ) diff --git a/studio/backend/run.py b/studio/backend/run.py index 481fd623f3..9cb7868949 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -1095,7 +1095,10 @@ def run_server( app.state.server_port = port if port and port > 0 else None # Direct (non-tunnel) base for the API panel; resolve 0.0.0.0 to the LAN IP. if port and port > 0: - _direct_host = _resolve_external_ip() if host == "0.0.0.0" else host + _direct_host = _resolve_external_ip() if host in ("0.0.0.0", "::") else host + # Bracket IPv6 literals so the URL is valid (http://[2405:...]:port). + if ":" in _direct_host and not _direct_host.startswith("["): + _direct_host = f"[{_direct_host}]" app.state.server_url = f"http://{_direct_host}:{port}" else: app.state.server_url = None diff --git a/studio/backend/state/tool_policy.py b/studio/backend/state/tool_policy.py index 9b0fc7d6cb..e0792321f9 100644 --- a/studio/backend/state/tool_policy.py +++ b/studio/backend/state/tool_policy.py @@ -10,15 +10,34 @@ Set by `unsloth run` at startup; consulted by the inference route gates. False -> CLI forced tools off for every request. """ -from typing import Optional +import contextvars +from contextlib import contextmanager +from typing import Iterator, Optional _tool_policy: Optional[bool] = None +# Per-request hard-off so public surfaces refuse tools even under a CLI `--enable-tools`. +_force_disabled: contextvars.ContextVar[bool] = contextvars.ContextVar( + "tool_policy_force_disabled", default = False +) + def get_tool_policy() -> Optional[bool]: + if _force_disabled.get(): + return False return _tool_policy +@contextmanager +def tools_force_disabled() -> Iterator[None]: + """Hard-disable server-side tools for the current async context.""" + token = _force_disabled.set(True) + try: + yield + finally: + _force_disabled.reset(token) + + def set_tool_policy(value: Optional[bool]) -> None: if value is not None and not isinstance(value, bool): raise TypeError(f"tool_policy must be Optional[bool], got {type(value).__name__}") diff --git a/studio/backend/tests/test_preview.py b/studio/backend/tests/test_preview.py new file mode 100644 index 0000000000..e131f99951 --- /dev/null +++ b/studio/backend/tests/test_preview.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import json +from pathlib import Path +import sys +import types as _types + +import pytest + + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +from utils.models.checkpoints import ( + list_preview_targets, + preview_ref, + resolve_preview_checkpoint, +) + + +def _make_run(outputs: Path) -> tuple[Path, Path]: + run = outputs / "unsloth_SmolLM-135M_1775412608" + run.mkdir(parents = True) + (run / "adapter_config.json").write_text( + json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) + ) + ckpt = run / "checkpoint-60" + ckpt.mkdir() + (ckpt / "adapter_config.json").write_text( + json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) + ) + return run, ckpt + + +def _point_outputs_root_at(monkeypatch, outputs: Path) -> None: + from utils.paths import storage_roots as _sr + from utils.models import checkpoints as _ckpt + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + # checkpoints imported outputs_root by name; patch that alias too (preview_ref uses it). + monkeypatch.setattr(_ckpt, "outputs_root", lambda: outputs) + + +def test_resolve_main_adapter_and_checkpoint(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + run, ckpt = _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + assert resolve_preview_checkpoint(run.name) == run + assert resolve_preview_checkpoint(run.name, "checkpoint-60") == ckpt + + +def test_resolve_missing_raises_not_found(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + with pytest.raises(FileNotFoundError): + resolve_preview_checkpoint("does-not-exist") + (outputs / "empty").mkdir() + with pytest.raises(FileNotFoundError): + resolve_preview_checkpoint("empty") + + +def test_resolve_rejects_traversal(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + with pytest.raises(ValueError): + resolve_preview_checkpoint("..", "etc") + + +def test_list_preview_targets_flattens_with_latest_flag(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + run, _ = _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + targets = list_preview_targets(str(outputs)) + by_ref = {t["ref"]: t for t in targets} + + assert by_ref[run.name]["is_latest"] is True + assert by_ref[run.name]["checkpoint"] is None + assert by_ref[f"{run.name}/checkpoint-60"]["is_latest"] is False + assert by_ref[f"{run.name}/checkpoint-60"]["checkpoint"] == "checkpoint-60" + assert all(t["base_model"] == "HuggingFaceTB/SmolLM-135M" for t in targets) + + +def test_preview_ref_flat_run_is_basename(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + run, _ = _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + assert preview_ref(str(run)) == run.name + + +def test_preview_ref_preserves_one_level_nesting(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _point_outputs_root_at(monkeypatch, outputs) + nested = outputs / "experiments" / "run1" + nested.mkdir(parents = True) + (nested / "adapter_config.json").write_text("{}") + + # /p route supports run/checkpoint, so a single level of nesting survives. + assert preview_ref(str(nested)) == "experiments/run1" + + +def test_preview_ref_none_for_unpreviewable_or_too_deep(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _point_outputs_root_at(monkeypatch, outputs) + + # Missing / no model artifact -> not previewable. + assert preview_ref(None) is None + empty = outputs / "empty" + empty.mkdir(parents = True) + assert preview_ref(str(empty)) is None + + # Too deep for the two-segment /p route -> no dead link. + deep = outputs / "a" / "b" / "run" + deep.mkdir(parents = True) + (deep / "adapter_config.json").write_text("{}") + assert preview_ref(str(deep)) is None + + # Outside outputs_root -> None. + outside = tmp_path / "elsewhere" + outside.mkdir() + (outside / "adapter_config.json").write_text("{}") + assert preview_ref(str(outside)) is None diff --git a/studio/backend/tests/test_preview_routes.py b/studio/backend/tests/test_preview_routes.py new file mode 100644 index 0000000000..d6edd4ef4d --- /dev/null +++ b/studio/backend/tests/test_preview_routes.py @@ -0,0 +1,293 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Security smoke for the public /p preview routes. + +Exercises the route layer with a real ``preview_router`` while stubbing the +expensive model calls (``load_model`` / ``openai_chat_completions``). Covers the +public-surface guarantees: path-traversal rejection, request sanitization +(tools / provider routing / use_adapter), asset-path containment, the page CSP +header + HTML escaping, and that the preview lock is held until a streaming +response is fully drained. +""" + +import asyncio +import json +from pathlib import Path +import sys +import types as _types + +import pytest + + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +# Mirror test_preview.py: the real `loggers` package pulls in heavy handlers. +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from fastapi.testclient import TestClient + +import routes.preview as preview +from models.inference import ChatCompletionRequest + + +def _make_run(outputs: Path, name: str = "demorun") -> Path: + run = outputs / name + run.mkdir(parents = True) + (run / "adapter_config.json").write_text( + json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) + ) + ckpt = run / "checkpoint-1" + ckpt.mkdir() + (ckpt / "adapter_config.json").write_text("{}") + return run + + +@pytest.fixture +def captured(): + return {} + + +@pytest.fixture +def client(tmp_path, monkeypatch, captured): + outputs = tmp_path / "outputs" + _make_run(outputs) + + # resolve_preview_checkpoint -> resolve_output_dir -> outputs_root(). + from utils.paths import storage_roots as _sr + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + + async def _fake_load_model(load_req, request, subject): + captured["load_path"] = load_req.model_path + return None + + async def _fake_chat(payload, request, subject): + captured["payload"] = payload + return {"ok": True} + + monkeypatch.setattr(preview, "load_model", _fake_load_model) + monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) + + app = FastAPI() + app.include_router(preview.router, prefix = "/p") + app.dependency_overrides[preview.get_current_subject] = lambda: "admin" + # raise_server_exceptions=False so a 5xx surfaces as a response, not a throw. + return TestClient(app, raise_server_exceptions = False) + + +# ── Page rendering ──────────────────────────────────────────────────────── + + +def test_page_renders_with_csp(client): + r = client.get("/p/demorun") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + csp = r.headers.get("content-security-policy", "") + assert "default-src 'self'" in csp + assert "base-uri 'none'" in csp + + +def test_page_escapes_title(tmp_path, monkeypatch, captured): + outputs = tmp_path / "outputs" + # Run dir name carries an HTML-special char; the page must escape it. + _make_run(outputs, name = "a None. + outputs = tmp_path / "outputs" + merged = outputs / "mergedrun" + merged.mkdir(parents = True) + (merged / "config.json").write_text(json.dumps({"_name_or_path": "some/base"})) + + from utils.paths import storage_roots as _sr + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + + async def _fake_load(load_req, request, subject): + return None + + async def _fake_chat(payload, request, subject): + captured["payload"] = payload + return {"ok": True} + + monkeypatch.setattr(preview, "load_model", _fake_load) + monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) + + app = FastAPI() + app.include_router(preview.router, prefix = "/p") + c = TestClient(app, raise_server_exceptions = False) + r = c.post( + "/p/mergedrun/v1/chat/completions", + json = {"messages": [{"role": "user", "content": "hi"}], "use_adapter": False}, + ) + assert r.status_code == 200 + assert captured["payload"].use_adapter is None + + +# ── Streaming lock lifetime ────────────────────────────────────────────────── + + +def test_streaming_holds_lock_until_drained(tmp_path, monkeypatch, captured): + outputs = tmp_path / "outputs" + _make_run(outputs) + from utils.paths import storage_roots as _sr + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + + async def _fake_load_model(load_req, request, subject): + return None + + async def _gen(): + yield b"data: {}\n\n" + yield b"data: [DONE]\n\n" + + async def _fake_chat(payload, request, subject): + return StreamingResponse(_gen()) + + monkeypatch.setattr(preview, "load_model", _fake_load_model) + monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) + + async def _run(): + assert not preview._preview_lock.locked() + payload = ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}]) + resp = await preview._serve_chat("demorun", None, payload, request = None) + # Lock must still be held: a second checkpoint must not swap the backend + # mid-stream. + assert preview._preview_lock.locked() + chunks = [c async for c in resp.body_iterator] + # Released only after the stream fully drains. + assert not preview._preview_lock.locked() + return chunks + + chunks = asyncio.run(_run()) + assert any(b"[DONE]" in c for c in chunks) + assert not preview._preview_lock.locked() diff --git a/studio/backend/utils/api_errors.py b/studio/backend/utils/api_errors.py index b1c55b61b9..cae8daf287 100644 --- a/studio/backend/utils/api_errors.py +++ b/studio/backend/utils/api_errors.py @@ -125,6 +125,12 @@ def is_anthropic_path(path: str) -> bool: return path.startswith("/v1/messages") +def wants_api_error_envelope(path: str) -> bool: + """True for the OpenAI/Anthropic-compatible surfaces: the ``/v1/*`` mount and + the preview ``/p/[/]/v1/*`` mount.""" + return path.startswith("/v1/") or (path.startswith("/p/") and "/v1/" in path) + + def error_body_for_path( path, message, @@ -183,15 +189,16 @@ def _summarize_validation_errors(errors) -> tuple: def install_api_error_handlers(app) -> None: """Register validation + HTTPException handlers that emit ``/v1/*`` envelopes. - Both handlers are global but only transform responses for paths starting with - ``/v1/``. Non-``/v1/`` paths reproduce FastAPI's default ``{"detail": ...}`` - behavior exactly so the Studio frontend keeps working. + Both handlers are global but only transform responses for OpenAI/Anthropic- + compatible surfaces (see :func:`wants_api_error_envelope`: the ``/v1/*`` mount + and the preview ``/p/.../v1/*`` mount). Every other path reproduces FastAPI's + default ``{"detail": ...}`` behavior exactly so the Studio frontend keeps working. """ @app.exception_handler(RequestValidationError) async def _handle_validation_error(request, exc): path = request.url.path - if path.startswith("/v1/"): + if wants_api_error_envelope(path): summary, param = _summarize_validation_errors(exc.errors()) return JSONResponse( status_code = 400, @@ -211,7 +218,7 @@ def install_api_error_handlers(app) -> None: # default http_exception_handler, which returns a bodiless Response. if not is_body_allowed_for_status_code(exc.status_code): return Response(status_code = exc.status_code, headers = headers) - if path.startswith("/v1/"): + if wants_api_error_envelope(path): detail = exc.detail # Already a fully-formed envelope: pass through untouched. if isinstance(detail, dict) and ("error" in detail or detail.get("type") == "error"): diff --git a/studio/backend/utils/models/checkpoints.py b/studio/backend/utils/models/checkpoints.py index 63f599d0df..d174f6677b 100644 --- a/studio/backend/utils/models/checkpoints.py +++ b/studio/backend/utils/models/checkpoints.py @@ -159,3 +159,64 @@ def scan_checkpoints( except Exception as e: logger.error(f"Error scanning checkpoints: {e}") return [] + + +def _is_model_dir(path: Path) -> bool: + return (path / "config.json").exists() or (path / "adapter_config.json").exists() + + +def has_preview_model(output_dir: Optional[str]) -> bool: + """True when ``output_dir`` holds a previewable root model (what ``/p/{run}`` + resolves). A cancelled run keeps ``output_dir`` but saves no root adapter.""" + if not output_dir: + return False + path = Path(output_dir) + return path.is_dir() and _is_model_dir(path) + + +def preview_ref(output_dir: Optional[str]) -> Optional[str]: + """``/p`` ref (``run`` or ``run/checkpoint``) relative to outputs_root, or None. + + Posix-joined so a nested output dir keeps a working link instead of collapsing + to its basename. None when not previewable, outside outputs_root, or deeper than + the two path segments the ``/p`` route matches (so the UI omits a dead link). + """ + if not has_preview_model(output_dir): + return None + try: + rel = Path(output_dir).resolve().relative_to(outputs_root().resolve()) + except (ValueError, OSError): + return None + parts = rel.parts + if not parts or len(parts) > 2: + return None + return "/".join(parts) + + +def resolve_preview_checkpoint(run: str, checkpoint: Optional[str] = None) -> Path: + relative = run if not checkpoint else f"{run}/{checkpoint}" + path = resolve_output_dir(relative) + if not path.is_dir() or not _is_model_dir(path): + raise FileNotFoundError( + f"No trained checkpoint at '{relative}'. Check the run/checkpoint name (see GET /p)." + ) + return path + + +def list_preview_targets(outputs_dir: str = str(outputs_root())) -> List[dict]: + targets: List[dict] = [] + for run_name, checkpoints, metadata in scan_checkpoints(outputs_dir): + for display_name, path, loss in checkpoints: + is_latest = display_name == run_name + checkpoint = None if is_latest else Path(path).name + targets.append( + { + "run": run_name, + "checkpoint": checkpoint, + "ref": run_name if is_latest else f"{run_name}/{checkpoint}", + "is_latest": is_latest, + "loss": loss, + "base_model": metadata.get("base_model"), + } + ) + return targets diff --git a/studio/frontend/src/features/studio/history-card-grid.tsx b/studio/frontend/src/features/studio/history-card-grid.tsx index dbe7fff01b..75ef1c2d85 100644 --- a/studio/frontend/src/features/studio/history-card-grid.tsx +++ b/studio/frontend/src/features/studio/history-card-grid.tsx @@ -24,7 +24,10 @@ import { useTrainingRuntimeStore, } from "@/features/training"; import { formatDuration } from "@/features/studio/sections/progress-section-lib"; +import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { cn } from "@/lib/utils"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { toast } from "@/lib/toast"; import { Delete02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; @@ -194,6 +197,28 @@ export function HistoryCardGrid({ const [manualFetchInFlight, setManualFetchInFlight] = useState(false); const { resumeTrainingRunFromHistory } = useTrainingActions(); const isStarting = useTrainingRuntimeStore((state) => state.isStarting); + // Copy-link base: Cloudflare tunnel > LAN host:port > origin. The tunnel + // registers shortly after startup, so poll (bounded) until it shows. + const cloudflareUrl = usePlatformStore((s) => s.cloudflareUrl); + const serverUrl = usePlatformStore((s) => s.serverUrl); + useEffect(() => { + if (cloudflareUrl) return; + let cancelled = false; + void (async () => { + for (let attempt = 0; attempt < 12 && !cancelled; attempt++) { + try { + await fetchDeviceType({ force: true }); + } catch { + // Ignore startup blips; copy-link falls back to serverUrl/origin. + } + if (cancelled || usePlatformStore.getState().cloudflareUrl) return; + await new Promise((r) => setTimeout(r, 2500)); + } + })(); + return () => { + cancelled = true; + }; + }, [cloudflareUrl]); const userControllerRef = useRef(null); const pollControllerRef = useRef(null); @@ -362,6 +387,8 @@ export function HistoryCardGrid({ const isRunning = run.status === "running"; const canResume = run.can_resume && !wasContinued; const isResuming = resumeTarget === run.id; + // Backend /p ref, gated on previewability + route-expressible depth. + const canCopyPreview = !!run.preview_ref; return (
onSelectRun(run.id)} onKeyDown={(e) => { @@ -411,6 +438,38 @@ export function HistoryCardGrid({ {isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")} )} + {canCopyPreview && ( + + )}

{run.loss_sparkline && run.loss_sparkline.length >= 2 && ( -
+
Date: Wed, 24 Jun 2026 06:37:41 -0700 Subject: [PATCH 099/306] Verify DiffusionGemma visual-server binary against approved checksums (#6635) ensure_diffusion_visual_server() downloaded the visual-server release asset with the unverified download_file() and marked it executable, bypassing the approved-checksum manifest that gates every other prebuilt llama.cpp artifact. The backend later auto-discovers that binary and launches it through DG_VISUAL_BIN, so a compromised or substituted release asset could place attacker-controlled native code in the install tree and have it executed under the Studio user. Require the matched asset to be present in the approved checksum manifest and download it through download_file_verified() with the published sha256. A name-matching asset that is absent from the manifest is refused rather than executed. Add regression tests covering the verified-download path and the refusal of an unapproved asset. --- studio/install_llama_prebuilt.py | 44 +++++-- .../test_install_llama_prebuilt_logic.py | 119 ++++++++++++++++++ 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 33cb709ba7..c7f34e39f2 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -4261,7 +4261,10 @@ def ensure_converter_scripts(install_dir: Path, llama_tag: str) -> None: def ensure_diffusion_visual_server( - install_dir: Path, host: HostInfo, release_tag: str | None + install_dir: Path, + host: HostInfo, + release_tag: str | None, + approved_checksums: ApprovedReleaseChecksums, ) -> None: """Best-effort placement of the DiffusionGemma visual-server binary next to llama-server in the install tree, so Studio can serve DiffusionGemma GGUFs @@ -4293,6 +4296,7 @@ def ensure_diffusion_visual_server( try: assets = github_release_assets(DEFAULT_PUBLISHED_REPO, release_tag) match = None + unapproved_matches: list[str] = [] for asset_name, url in assets.items(): low = asset_name.lower() if "llama-diffusion-gemma-visual-server" not in low: @@ -4301,19 +4305,39 @@ def ensure_diffusion_visual_server( continue if (not host.is_windows) and low.endswith(".exe"): continue - match = (asset_name, url) + # This binary is chmod'd executable and later launched by the + # backend, so it must be covered by the approved checksum manifest + # just like every other prebuilt artifact. An asset that matches the + # name but is missing from the manifest is refused rather than run. + approved = approved_checksums.artifacts.get(asset_name) + if approved is None: + unapproved_matches.append(asset_name) + continue + match = (asset_name, url, approved.sha256) break if match is None: - log( - "diffusion visual server not found in the published release; native " - "DiffusionGemma serving needs DG_VISUAL_BIN or a source build" - ) + if unapproved_matches: + log( + "diffusion visual server asset(s) were present but omitted from the " + "approved checksum manifest; refusing unverified native executable: " + + ", ".join(unapproved_matches) + ) + else: + log( + "diffusion visual server not found in the published release; native " + "DiffusionGemma serving needs DG_VISUAL_BIN or a source build" + ) return bin_dir.mkdir(parents = True, exist_ok = True) - download_file(match[1], target) + download_file_verified( + match[1], + target, + expected_sha256 = match[2], + label = f"diffusion visual server {match[0]}", + ) if not host.is_windows: target.chmod(0o755) - log(f"installed diffusion visual server: {match[0]}") + log(f"installed verified diffusion visual server: {match[0]}") except Exception as exc: log( "diffusion visual server fetch skipped " @@ -6637,7 +6661,9 @@ def install_prebuilt( f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" ) try: - ensure_diffusion_visual_server(install_dir, host, plan.release_tag) + ensure_diffusion_visual_server( + install_dir, host, plan.release_tag, plan.approved_checksums + ) except Exception as exc: log( "diffusion visual server step skipped; install remains valid " diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py index a5a1131ecc..9ee8759bb4 100644 --- a/tests/studio/install/test_install_llama_prebuilt_logic.py +++ b/tests/studio/install/test_install_llama_prebuilt_logic.py @@ -37,6 +37,41 @@ install_prebuilt = INSTALL_LLAMA_PREBUILT.install_prebuilt write_prebuilt_metadata = INSTALL_LLAMA_PREBUILT.write_prebuilt_metadata existing_install_matches_plan = INSTALL_LLAMA_PREBUILT.existing_install_matches_plan existing_install_matches_choice = INSTALL_LLAMA_PREBUILT.existing_install_matches_choice +ensure_diffusion_visual_server = INSTALL_LLAMA_PREBUILT.ensure_diffusion_visual_server + + +def linux_host() -> HostInfo: + return HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + +def approved_release_checksums_for_asset(asset_name: str, sha256: str) -> ApprovedReleaseChecksums: + return ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "b9334", + upstream_tag = "b9334", + artifacts = { + asset_name: ApprovedArtifactHash( + asset_name = asset_name, + sha256 = sha256, + repo = "unslothai/llama.cpp", + kind = "diffusion-visual-server", + ) + }, + ) def approved_checksums_for( @@ -2828,3 +2863,87 @@ def test_validate_prebuilt_choice_approved_validation_runs_when_flag_enabled(tmp monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "_RUN_STAGED_PREBUILT_VALIDATION", True) calls = _run_validate_prebuilt_choice(monkeypatch, tmp_path, expected_sha256 = "ab" * 32) assert calls == {"quantize": 1, "server": 1} + + +def test_diffusion_visual_server_uses_approved_checksum_download(monkeypatch, tmp_path: Path): + asset_name = "llama-diffusion-gemma-visual-server-linux-x64" + expected_sha = "a" * 64 + asset_url = "https://github.com/unslothai/llama.cpp/releases/download/b9334/" + asset_name + calls: list[tuple[str, Path, str | None, str | None]] = [] + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: {asset_name: asset_url}, + ) + + def fake_download_file(url, destination): + raise AssertionError("diffusion visual server must not use unverified download_file") + + def fake_download_file_verified(url, destination, *, expected_sha256, label): + calls.append((url, Path(destination), expected_sha256, label)) + Path(destination).write_bytes(b"verified visual server") + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "download_file_verified", fake_download_file_verified + ) + + ensure_diffusion_visual_server( + tmp_path / "install", + linux_host(), + "b9334", + approved_release_checksums_for_asset(asset_name, expected_sha), + ) + + target = tmp_path / "install" / "build" / "bin" / "llama-diffusion-gemma-visual-server" + assert calls == [ + ( + asset_url, + target, + expected_sha, + f"diffusion visual server {asset_name}", + ) + ] + assert target.read_bytes() == b"verified visual server" + assert target.stat().st_mode & 0o777 == 0o755 + + +def test_diffusion_visual_server_refuses_unapproved_release_asset(monkeypatch, tmp_path: Path): + asset_name = "llama-diffusion-gemma-visual-server-attacker-linux" + verified_calls: list[str] = [] + raw_calls: list[str] = [] + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: {asset_name: "https://example.test/" + asset_name}, + ) + + def fake_download_file(url, destination): + raw_calls.append(url) + + def fake_download_file_verified(url, destination, *, expected_sha256, label): + verified_calls.append(url) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "download_file_verified", fake_download_file_verified + ) + + ensure_diffusion_visual_server( + tmp_path / "install", + linux_host(), + "b9334", + ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "b9334", + upstream_tag = "b9334", + artifacts = {}, + ), + ) + + target = tmp_path / "install" / "build" / "bin" / "llama-diffusion-gemma-visual-server" + assert not target.exists() + assert raw_calls == [] + assert verified_calls == [] From ab6c9ecfee545869d56cc6eddd1babc3f7f36fba Mon Sep 17 00:00:00 2001 From: oobabooga Date: Wed, 24 Jun 2026 11:37:08 -0300 Subject: [PATCH 100/306] Studio: honor `stream=false` on the GGUF agentic tool path (#6570) (#6618) * Studio: honor stream=false on the GGUF agentic tool path (#6570) * Studio: dedup the #6570 non-streaming tool tests and cover cached_tokens * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: cover the cached_tokens metadata fix and clarify the drain comment (#6570) * Studio: align the GGUF tool drain naming and tighten its comment (#6570) --------- 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> --- studio/backend/core/inference/llama_cpp.py | 15 +- studio/backend/routes/inference.py | 126 ++++++++++++- .../tests/test_gguf_tool_non_streaming.py | 172 ++++++++++++++++++ .../backend/tests/test_llama_cpp_tool_loop.py | 77 ++++++++ .../tests/test_openai_tool_passthrough.py | 2 + 5 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 studio/backend/tests/test_gguf_tool_non_streaming.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9c22db4fec..152a3f19b2 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -7881,13 +7881,18 @@ class LlamaCppBackend: _mt["predicted_per_second"] = _mt["predicted_n"] / ( _mt["predicted_ms"] / 1000.0 ) + _usage = { + "prompt_tokens": _fp, + "completion_tokens": _tc, + "total_tokens": _fp + _tc, + } + # Preserve KV-cache hit details (cached_tokens) so the tool path + # reports them like the standard non-tool path does, not always 0. + if _fu.get("prompt_tokens_details"): + _usage["prompt_tokens_details"] = _fu["prompt_tokens_details"] return { "type": "metadata", - "usage": { - "prompt_tokens": _fp, - "completion_tokens": _tc, - "total_tokens": _fp + _tc, - }, + "usage": _usage, "timings": _mt, "finish_reason": finish_reason, } diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 2e2c38933e..8b0981cd2b 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5425,15 +5425,123 @@ async def openai_chat_completions( pass _tracker.__exit__(None, None, None) - return _SameTaskStreamingResponse( - gguf_tool_stream(), - media_type = "text/event-stream", - headers = { - "Cache-Control": "no-cache", - "Connection": "close", - "X-Accel-Buffering": "no", - }, - ) + if payload.stream: + return _SameTaskStreamingResponse( + gguf_tool_stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "close", + "X-Accel-Buffering": "no", + }, + ) + + # Non-streaming JSON: drain the agentic generator into one + # ChatCompletion, like the standard GGUF `else` branch. stream:false + # with tools enabled used to return an SSE body, breaking + # non-streaming clients; `unsloth studio run --model` forces tools on + # process-wide, so plain requests reach this path (#6570). + def _drain_gguf_tool_loop(): + full_text = "" + usage = None + finish = None + gen = gguf_generate_with_tools() + try: + for event in gen: + if cancel_event.is_set(): + break + if event.get("type") == "metadata": + usage = event.get("usage") + finish = event.get("finish_reason") + elif event.get("type") == "content": + # Content is cumulative within a turn and resets + # between turns, so the last event holds the final + # turn's text. As in the safetensors drain, a visible + # preamble emitted before a tool call (its own earlier + # turn) isn't carried -- only the final turn is. + full_text = _strip_tool_xml_for_display( + event.get("text", ""), + auto_heal_tool_calls = _gguf_auto_heal_tool_calls, + ) + return full_text, usage, finish + finally: + # Close the generator on early break/cancel so the underlying + # llama-server stream socket is released, like the SSE path. + try: + gen.close() + except (RuntimeError, ValueError): + pass + + try: + full_text, completion_usage, completion_finish = await asyncio.to_thread( + _drain_gguf_tool_loop + ) + reasoning_text, visible_text = _extract_responses_reasoning( + full_text, + parse_think_markers = _responses_should_parse_think_markers( + payload, llama_backend + ), + ) + message_kwargs = {"content": visible_text} + if reasoning_text: + message_kwargs["reasoning_content"] = reasoning_text + _usage = completion_usage or {} + _prompt_tokens = _usage.get("prompt_tokens") or 0 + _completion_tokens = _usage.get("completion_tokens") or 0 + response = ChatCompletion( + id = completion_id, + created = created, + model = model_name, + choices = [ + CompletionChoice( + message = CompletionMessage(**message_kwargs), + finish_reason = _clamp_finish_reason(completion_finish), + ) + ], + usage = CompletionUsage( + prompt_tokens = _prompt_tokens, + completion_tokens = _completion_tokens, + total_tokens = _prompt_tokens + _completion_tokens, + prompt_tokens_details = _prompt_tokens_details( + _usage.get("prompt_tokens_details") + ), + ), + ) + api_monitor.set_reply(monitor_id, visible_text) + _monitor_usage( + monitor_id, + { + "prompt_tokens": _prompt_tokens, + "completion_tokens": _completion_tokens, + "total_tokens": _prompt_tokens + _completion_tokens, + }, + _monitor_context_length(), + ) + api_monitor.finish( + monitor_id, "cancelled" if cancel_event.is_set() else "completed" + ) + return _model_json_response(response) + except Exception as e: + logger.error(f"Error during GGUF tool completion: {e}", exc_info = True) + api_monitor.fail(monitor_id, _friendly_error(e)) + # Recover if an MTP+tensor crash killed the server. + get_llama_cpp_backend()._maybe_recover_from_mtp_crash(e) + # An over-context prompt makes llama-server return 400; map any + # upstream 4xx to a 400 client error rather than leaking a 500. + _cls = _classify_llama_generation_error(e) + if _cls is not None: + raise HTTPException( + status_code = 400, + detail = openai_error_body( + _friendly_error(e), + status = 400, + code = "context_length_exceeded" if _cls else None, + param = "messages", + ), + ) + raise HTTPException(status_code = 500, detail = safe_error_detail(e)) + finally: + _tracker.__exit__(None, None, None) # ── Standard GGUF path (no tools) ───────────────────── diff --git a/studio/backend/tests/test_gguf_tool_non_streaming.py b/studio/backend/tests/test_gguf_tool_non_streaming.py new file mode 100644 index 0000000000..d9044824cb --- /dev/null +++ b/studio/backend/tests/test_gguf_tool_non_streaming.py @@ -0,0 +1,172 @@ +# 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 tests for `stream:false` on the GGUF agentic tool path (#6570). + +When server-side tools are enabled (e.g. `unsloth studio run --model ...`, +which forces the tool policy on process-wide), a plain chat request used to be +routed into the tool loop, which returned an SSE body *regardless* of +`stream:false` -- breaking non-streaming clients and health checks like +LiteLLM. These tests drive the real route with a fake tool-capable backend and +assert the non-streaming path now returns a single JSON `chat.completion`, +while `stream:true` still streams. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from auth.authentication import get_current_subject +import routes.inference as inference_route + + +class _ToolGgufBackend: + is_loaded = True + model_identifier = "test/model.gguf" + _is_audio = False + is_vision = False + supports_tools = True + + def generate_chat_completion_with_tools(self, **kwargs): + # The agentic loop runs one tool, then the model answers. Event shapes + # mirror the real GGUF loop (tool_start/tool_end/content/metadata). + yield { + "type": "tool_start", + "tool_name": "python", + "tool_call_id": "call_1", + "arguments": {"code": "print(6 * 7)"}, + } + yield { + "type": "tool_end", + "tool_name": "python", + "tool_call_id": "call_1", + "result": "42\n", + } + yield {"type": "content", "text": "The answer is 42."} + yield { + "type": "metadata", + "usage": {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16}, + "timings": {"prompt_n": 11, "predicted_n": 5}, + "finish_reason": "stop", + } + + +def _client(monkeypatch, backend = None): + monkeypatch.setattr( + inference_route, "get_llama_cpp_backend", lambda: backend or _ToolGgufBackend() + ) + # Tools forced on -- the same effect as the CLI `run --model` tool policy. + monkeypatch.setattr(inference_route, "_effective_enable_tools", lambda payload: True) + + async def _fake_select(payload, **_kwargs): + return [{"type": "function", "function": {"name": "python"}}] + + monkeypatch.setattr(inference_route, "_select_request_tools", _fake_select) + + app = FastAPI() + app.include_router(inference_route.router) + app.dependency_overrides[get_current_subject] = lambda: "test-user" + return TestClient(app) + + +def _payload(stream: bool): + return { + "messages": [{"role": "user", "content": "What is 6 * 7? Use python."}], + "stream": stream, + "enable_tools": True, + } + + +def test_non_streaming_tool_call_returns_single_json(monkeypatch): + response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = False)) + + assert response.status_code == 200 + # The bug returned text/event-stream here; it must be a single JSON object. + assert response.headers["content-type"].startswith("application/json") + + body = response.json() + assert body["object"] == "chat.completion" + choice = body["choices"][0] + assert choice["message"]["content"] == "The answer is 42." + assert choice["finish_reason"] == "stop" + assert body["usage"]["prompt_tokens"] == 11 + assert body["usage"]["completion_tokens"] == 5 + assert body["usage"]["total_tokens"] == 16 + + +def test_streaming_tool_call_still_streams(monkeypatch): + # The parallel path is untouched: stream:true keeps returning SSE. + response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = True)) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "The answer is 42." in response.text + assert "data: [DONE]" in response.text + + +class _EventsBackend(_ToolGgufBackend): + """Tool backend that yields a caller-supplied event list.""" + + def __init__(self, events): + self._events = events + + def generate_chat_completion_with_tools(self, **kwargs): + yield from self._events + + +def test_non_streaming_missing_usage_defaults_to_zero(monkeypatch): + # No metadata event at all: usage zero-defaults and finish_reason falls back. + events = [{"type": "content", "text": "hi"}] + response = _client(monkeypatch, _EventsBackend(events)).post( + "/chat/completions", json = _payload(stream = False) + ) + + assert response.status_code == 200 + body = response.json() + assert body["choices"][0]["message"]["content"] == "hi" + assert body["choices"][0]["finish_reason"] == "stop" + assert body["usage"]["prompt_tokens"] == 0 + assert body["usage"]["completion_tokens"] == 0 + assert body["usage"]["total_tokens"] == 0 + + +def test_non_streaming_preserves_length_finish_reason(monkeypatch): + events = [ + {"type": "content", "text": "truncated"}, + { + "type": "metadata", + "usage": {"prompt_tokens": 3, "completion_tokens": 9}, + "finish_reason": "length", + }, + ] + response = _client(monkeypatch, _EventsBackend(events)).post( + "/chat/completions", json = _payload(stream = False) + ) + + assert response.status_code == 200 + body = response.json() + assert body["choices"][0]["finish_reason"] == "length" + # total_tokens is derived when the server omits it. + assert body["usage"]["total_tokens"] == 12 + + +def test_non_streaming_preserves_cached_tokens(monkeypatch): + # KV-cache hit details from the metadata event must survive into the body + # (the tool path used to drop them and always report cached_tokens=0). + events = [ + {"type": "content", "text": "hi"}, + { + "type": "metadata", + "usage": { + "prompt_tokens": 20, + "completion_tokens": 4, + "prompt_tokens_details": {"cached_tokens": 16}, + }, + "finish_reason": "stop", + }, + ] + response = _client(monkeypatch, _EventsBackend(events)).post( + "/chat/completions", json = _payload(stream = False) + ) + + assert response.status_code == 200 + assert response.json()["usage"]["prompt_tokens_details"]["cached_tokens"] == 16 diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py index 56e028bd5a..05d2a0b80a 100644 --- a/studio/backend/tests/test_llama_cpp_tool_loop.py +++ b/studio/backend/tests/test_llama_cpp_tool_loop.py @@ -1736,3 +1736,80 @@ def test_empty_tool_call_id_does_not_emit_provisional_card(monkeypatch): assert provisional == [] # The real call still executes despite the missing id. assert calls == [("python", {"code": big_code})] + + +def _usage_done(usage: dict, finish_reason: str = "stop") -> str: + """A terminal SSE chunk carrying llama-server's ``usage`` block, the way the + real server reports it on the final chunk of a completion.""" + return ( + "data: " + + json.dumps( + { + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], + "usage": usage, + } + ) + + "\n" + ) + + +def test_metadata_event_preserves_prompt_tokens_details(monkeypatch): + """The tool loop's metadata event must carry llama-server's + ``prompt_tokens_details`` (KV-cache hits) through ``_build_metadata_event``, + so the route reports real ``cached_tokens`` instead of always 0 (#6570). + + This drives the *real* generator; the route-level test feeds a pre-built + metadata event and so never exercises this code. + """ + stream = [ + _sse({"content": "The answer is 42."}), + _usage_done( + { + "prompt_tokens": 20, + "completion_tokens": 4, + "prompt_tokens_details": {"cached_tokens": 16}, + } + ), + _done(), + ] + payloads: list[dict] = [] + backend = _make_backend(monkeypatch, [stream], payloads) + + events = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "hi"}], + tools = [], + max_tool_iterations = 1, + ) + ) + + metadata = [e for e in events if e.get("type") == "metadata"] + assert metadata, "expected a metadata event" + usage = metadata[-1]["usage"] + assert usage["prompt_tokens_details"] == {"cached_tokens": 16} + assert usage["prompt_tokens"] == 20 + assert usage["completion_tokens"] == 4 + + +def test_metadata_event_omits_prompt_tokens_details_when_absent(monkeypatch): + """No KV-cache block from the server -> the key isn't fabricated, so the + route falls back to its 0-default instead of reading a bogus value.""" + stream = [ + _sse({"content": "hi"}), + _usage_done({"prompt_tokens": 5, "completion_tokens": 2}), + _done(), + ] + payloads: list[dict] = [] + backend = _make_backend(monkeypatch, [stream], payloads) + + events = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "hi"}], + tools = [], + max_tool_iterations = 1, + ) + ) + + metadata = [e for e in events if e.get("type") == "metadata"] + assert metadata, "expected a metadata event" + assert "prompt_tokens_details" not in metadata[-1]["usage"] diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index aaef9e4dcc..aa36c6fed4 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -1349,6 +1349,7 @@ class TestGgufVisionToolRouting: model = "default", enable_tools = True, enabled_tools = ["web_search"], + stream = True, messages = [ { "role": "user", @@ -1408,6 +1409,7 @@ class TestGgufVisionToolRouting: enable_tools = True, enabled_tools = ["web_search"], parallel_tool_calls = False, + stream = True, messages = [{"role": "user", "content": "search once"}], ) From a3954edd15e4a03b584d60940173b99c17f45922 Mon Sep 17 00:00:00 2001 From: Long Yixing Date: Wed, 24 Jun 2026 22:39:20 +0800 Subject: [PATCH 101/306] Fix Studio GGUF variant expansion crash (#6636) * fix: handle empty GGUF variants * fix: gate local GGUF expansion * fix: normalize GGUF variant payload --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- .../assistant-ui/model-selector/pickers.tsx | 83 +++++++++++++++---- 1 file changed, 68 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 90c1c04106..386f233c7b 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -570,6 +570,52 @@ function ModelRow({ // ── GGUF Variant Expander ──────────────────────────────────── +function isValidGgufVariant(variant: unknown): variant is GgufVariantDetail { + if (!variant || typeof variant !== "object") return false; + const candidate = variant as Partial; + return ( + typeof candidate.filename === "string" && + candidate.filename.length > 0 && + typeof candidate.quant === "string" && + candidate.quant.length > 0 && + typeof candidate.size_bytes === "number" && + Number.isFinite(candidate.size_bytes) && + candidate.size_bytes >= 0 && + (candidate.downloaded === undefined || + typeof candidate.downloaded === "boolean") + ); +} + +function normalizeGgufVariantsResponse(res: { + variants?: unknown; + default_variant?: unknown; + has_vision?: unknown; + context_length?: unknown; +} | null | undefined): { + variants: GgufVariantDetail[]; + defaultVariant: string | null; + hasVision: boolean; + contextLength: number | null; +} { + const contextLength = res?.context_length; + return { + variants: (Array.isArray(res?.variants) ? res.variants : []).filter( + isValidGgufVariant, + ), + defaultVariant: + typeof res?.default_variant === "string" && res.default_variant.length > 0 + ? res.default_variant + : null, + hasVision: res?.has_vision === true, + contextLength: + typeof contextLength === "number" && + Number.isFinite(contextLength) && + contextLength >= 0 + ? contextLength + : null, + }; +} + function GgufVariantExpander({ repoId, onSelect, @@ -622,11 +668,12 @@ function GgufVariantExpander({ listGgufVariants(repoId) .then((res) => { if (canceled) return; - setVariants(res.variants); - setDefaultVariant(res.default_variant); - setHasVision(res.has_vision); - onHasVision?.(res.has_vision); - setNativeContext(res.context_length ?? null); + const normalized = normalizeGgufVariantsResponse(res); + setVariants(normalized.variants); + setDefaultVariant(normalized.defaultVariant); + setHasVision(normalized.hasVision); + onHasVision?.(normalized.hasVision); + setNativeContext(normalized.contextLength); }) .catch((err) => { if (canceled) return; @@ -694,19 +741,25 @@ function GgufVariantExpander({ // If the recommended variant is OOM, pick the largest fitting one; // if all are OOM, recommend the smallest. const effectiveRecommended = useMemo(() => { - if (!variants || totalBudgetGb <= 0) return defaultVariant; + if (!variants || variants.length === 0 || totalBudgetGb <= 0) { + return defaultVariant; + } const defaultV = variants.find((v) => v.quant === defaultVariant); if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom") return defaultVariant; // Largest non-OOM variant (best quality that fits) - const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom"); + const fitting = variants.filter( + (v) => getGgufFit(v.size_bytes) !== "oom", + ); if (fitting.length > 0) { fitting.sort((a, b) => b.size_bytes - a.size_bytes); return fitting[0].quant; } // All OOM -- recommend smallest (most likely to partially run) - const sorted = [...variants].sort((a, b) => a.size_bytes - b.size_bytes); - return sorted[0].quant; + const sorted = [...variants].sort( + (a, b) => a.size_bytes - b.size_bytes, + ); + return sorted[0]?.quant ?? defaultVariant; }, [variants, defaultVariant, totalBudgetGb, getGgufFit]); const sortedVariants = useMemo(() => { @@ -2901,7 +2954,7 @@ export function HubModelPicker({ } }} onArrowDownIntoChildren={ - isGgufExpanded(m.id) + isGguf && !isDirectGguf && isGgufExpanded(m.id) ? () => { const focused = focusFirstChildOption(optionKey); @@ -2911,7 +2964,7 @@ export function HubModelPicker({ } vramStatus={null} /> - {isGgufExpanded(m.id) && ( + {isGguf && !isDirectGguf && isGgufExpanded(m.id) && ( { const focused = focusFirstChildOption(optionKey); @@ -2998,7 +3051,7 @@ export function HubModelPicker({ } vramStatus={null} /> - {!isGgufFile && isGgufExpanded(m.id) && ( + {isGguf && !isGgufFile && isGgufExpanded(m.id) && ( focusFirstChildOption(optionKey) : undefined } vramStatus={null} /> - {!isGgufFile && isGgufExpanded(m.id) && ( + {isGguf && !isGgufFile && isGgufExpanded(m.id) && ( Date: Wed, 24 Jun 2026 17:34:18 -0700 Subject: [PATCH 102/306] Installer: make UV_OVERRIDE space-safe on Apple Silicon (#6503) (#6639) * Installer: make UV_OVERRIDE space-safe on Apple Silicon (#6503) On Apple Silicon, install.sh exports UV_OVERRIDE pointing at the bundled overrides-darwin-arm64.txt. uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path containing a space (e.g. /Users/me/Open Source/unsloth) truncates the value and every later uv call aborts with 'error: File not found: ' (the PyTorch install step in #6503). Copy the overrides file into a space-free temp dir and point uv at the copy when the path contains a space, mirroring the macOS/Linux handling already merged for the Python installer in #6534. The temp dir is removed in the exit trap, and the code falls back to the original path when no space-free temp dir is available, so the no-space and non-macOS paths are unchanged. Adds tests/sh/test_install_uv_override_space.sh, which extracts and runs the install.sh hardening block and checks the spaced, no-space, and spaced-TMPDIR fallback cases. * Installer: match all whitespace (not just spaces) in UV_OVERRIDE handling uv splits UV_OVERRIDE on any whitespace, so use the POSIX class *[[:space:]]* rather than a literal space in install.sh (catches tabs and newlines in the path too) and the matching test assertions. Use the portable awk bracket expression [$] instead of \$ in the extraction so the test runs the same under BSD awk (macOS) and GNU awk (Linux). Adds a tab-in-path case. * Installer: clear _UV_OVERRIDE_TMPDIR before the exit trap The exit trap rm -rf's _UV_OVERRIDE_TMPDIR. Initialize it to empty before registering the trap so an inherited environment value can never be removed; only a temp dir this script creates (Apple Silicon, spaced path) is cleaned. Adds a structural test asserting the init precedes the trap. * Run the install.sh UV_OVERRIDE space test in CI via a pytest wrapper The Shell installer tests job uses a fixed script list (not tests/run_all.sh), so the new shell test would not run on PRs. Add a pytest wrapper under tests/python/ that invokes it; the auto-discovered repo CPU test job collects tests/python/ and so executes the Apple Silicon spaced-path regression. * [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> --- install.sh | 23 ++++ .../python/test_install_uv_override_space.py | 31 +++++ tests/run_all.sh | 1 + tests/sh/test_install_uv_override_space.sh | 112 ++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 tests/python/test_install_uv_override_space.py create mode 100755 tests/sh/test_install_uv_override_space.sh diff --git a/install.sh b/install.sh index b3eaa61003..548e6f702a 100755 --- a/install.sh +++ b/install.sh @@ -447,8 +447,12 @@ _on_install_exit() { if [ "$_status" -ne 0 ]; then _restore_studio_venv_replacement fi + [ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true exit "$_status" } +# Empty so an inherited value can never reach the trap's rm; only a temp dir +# this script creates below (Apple Silicon, spaced path) is ever removed. +_UV_OVERRIDE_TMPDIR="" trap _on_install_exit EXIT # ── Helper: download a URL to a file (supports curl and wget) ── @@ -1427,6 +1431,25 @@ fi if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then _OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt" if [ -f "$_OVERRIDES_FILE" ]; then + # uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace + # truncates it and aborts every later uv call (issue #6503). Hand uv a copy. + case "$_OVERRIDES_FILE" in + *[[:space:]]*) + _UV_OVERRIDE_TMPDIR=$(mktemp -d 2>/dev/null) || _UV_OVERRIDE_TMPDIR="" + case "$_UV_OVERRIDE_TMPDIR" in + "") ;; + *[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;; + *) + if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then + _OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" + else + rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true + _UV_OVERRIDE_TMPDIR="" + fi + ;; + esac + ;; + esac export UV_OVERRIDE="$_OVERRIDES_FILE" fi fi diff --git a/tests/python/test_install_uv_override_space.py b/tests/python/test_install_uv_override_space.py new file mode 100644 index 0000000000..86d918b043 --- /dev/null +++ b/tests/python/test_install_uv_override_space.py @@ -0,0 +1,31 @@ +"""Run the install.sh UV_OVERRIDE space-safety shell test (issue #6503) under +pytest, so the auto-discovered CPU test job executes it. The dedicated +`Shell installer tests` CI job runs a fixed script list that this is not part +of, so without this wrapper the regression would only be covered locally via +tests/run_all.sh. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SHELL_TEST = REPO_ROOT / "tests" / "sh" / "test_install_uv_override_space.sh" + + +@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX shell installer test") +@pytest.mark.skipif(shutil.which("bash") is None, reason = "bash not available") +def test_install_uv_override_space_shell(): + assert SHELL_TEST.is_file(), f"missing shell test: {SHELL_TEST}" + proc = subprocess.run( + ["bash", str(SHELL_TEST)], + capture_output = True, + text = True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "ALL PASSED" in proc.stdout, proc.stdout + proc.stderr diff --git a/tests/run_all.sh b/tests/run_all.sh index 18182d9db7..d03f4c4d4f 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -15,6 +15,7 @@ sh "$TESTS_DIR/sh/test_resolve_cuda_archs.sh" sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh" sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh" sh "$TESTS_DIR/sh/test_torch_flavor.sh" +sh "$TESTS_DIR/sh/test_install_uv_override_space.sh" echo "" echo "=== Python tests ===" diff --git a/tests/sh/test_install_uv_override_space.sh b/tests/sh/test_install_uv_override_space.sh new file mode 100755 index 0000000000..07ef36295a --- /dev/null +++ b/tests/sh/test_install_uv_override_space.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path with a space +# truncates it and aborts every later uv call (issue #6503). install.sh must hand +# uv a space-free copy. Exercises the real install.sh hardening block. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Extract the UV_OVERRIDE hardening block (outer case ... esac plus the export) +# and run it directly, so the test tracks install.sh rather than a copy of it. +BLOCK=$(awk ' + /case "[$]_OVERRIDES_FILE" in/ { grab = 1 } + grab { print } + grab && /export UV_OVERRIDE="[$]_OVERRIDES_FILE"/ { exit } +' "$INSTALL_SH") +if ! printf '%s' "$BLOCK" | grep -q 'export UV_OVERRIDE'; then + echo " FAIL: could not extract UV_OVERRIDE block from install.sh" + exit 1 +fi + +run_block() { + _OVERRIDES_FILE="$1" + _UV_OVERRIDE_TMPDIR="" + unset UV_OVERRIDE + eval "$BLOCK" +} + +echo "=== test_install_uv_override_space ===" + +# 1. Spaced path -> space-free copy with identical contents, temp dir tracked. +WORK=$(mktemp -d) +mkdir -p "$WORK/Open Source" +SRC="$WORK/Open Source/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC" +run_block "$SRC" +case "$UV_OVERRIDE" in + *[[:space:]]*) bad "spaced path: UV_OVERRIDE still contains whitespace ($UV_OVERRIDE)" ;; + *) ok "spaced path: UV_OVERRIDE is whitespace-free" ;; +esac +[ "$UV_OVERRIDE" != "$SRC" ] && ok "spaced path: points at a copy" || bad "spaced path: not copied" +[ "$(cat "$UV_OVERRIDE" 2>/dev/null)" = "transformers>=4.57.6" ] \ + && ok "spaced path: copy contents identical" || bad "spaced path: contents differ" +{ [ -n "$_UV_OVERRIDE_TMPDIR" ] && [ -d "$_UV_OVERRIDE_TMPDIR" ]; } \ + && ok "spaced path: temp dir tracked for cleanup" || bad "spaced path: temp dir not tracked" +# The exit-trap cleanup (_on_install_exit) must then remove it. +[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true +[ ! -d "$_UV_OVERRIDE_TMPDIR" ] && ok "spaced path: temp dir removable" || bad "spaced path: temp dir lingers" +rm -rf "$WORK" + +# 2. No-space path -> passthrough, no temp dir. +PLAIN=$(mktemp -d) +PSRC="$PLAIN/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$PSRC" +run_block "$PSRC" +[ "$UV_OVERRIDE" = "$PSRC" ] && ok "no-space path: UV_OVERRIDE unchanged" || bad "no-space path: changed ($UV_OVERRIDE)" +[ -z "$_UV_OVERRIDE_TMPDIR" ] && ok "no-space path: no temp dir created" || bad "no-space path: temp dir created" +rm -rf "$PLAIN" + +# 3. TMPDIR itself contains a space -> fall back to the original path, no leak. +WORK2=$(mktemp -d) +mkdir -p "$WORK2/Open Source" "$WORK2/tmp dir" +SRC2="$WORK2/Open Source/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC2" +RES=$( TMPDIR="$WORK2/tmp dir"; export TMPDIR; run_block "$SRC2" + printf 'UV_OVERRIDE=%s\nTMPDIR_VAR=%s\n' "$UV_OVERRIDE" "$_UV_OVERRIDE_TMPDIR" ) +echo "$RES" | grep -qx "UV_OVERRIDE=$SRC2" \ + && ok "spaced TMPDIR: falls back to original path" || bad "spaced TMPDIR: did not fall back ($RES)" +echo "$RES" | grep -qx "TMPDIR_VAR=" \ + && ok "spaced TMPDIR: no temp dir tracked" || bad "spaced TMPDIR: temp dir tracked" +# mktemp may have created a dir under the spaced TMPDIR; it must not be leaked. +_leftover=$(find "$WORK2/tmp dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n1) +[ -z "$_leftover" ] && ok "spaced TMPDIR: no leaked temp dir" || bad "spaced TMPDIR: leaked $_leftover" +rm -rf "$WORK2" + +# 4. A tab in the path is whitespace uv also splits on -> copied like a space. +WORK3=$(mktemp -d) +TABDIR=$(printf 'Open\tSource') +mkdir -p "$WORK3/$TABDIR" +SRC3="$WORK3/$TABDIR/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC3" +run_block "$SRC3" +case "$UV_OVERRIDE" in + *[[:space:]]*) bad "tab path: UV_OVERRIDE still contains whitespace" ;; + *) ok "tab path: UV_OVERRIDE is whitespace-free" ;; +esac +[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true +rm -rf "$WORK3" + +# 5. install.sh must clear _UV_OVERRIDE_TMPDIR before registering the exit trap, +# so an inherited value can never reach the trap's rm -rf. +_init_line=$(grep -n '^_UV_OVERRIDE_TMPDIR=""' "$INSTALL_SH" | head -n1 | cut -d: -f1) +_trap_line=$(grep -n '^trap _on_install_exit EXIT' "$INSTALL_SH" | head -n1 | cut -d: -f1) +{ [ -n "$_init_line" ] && [ -n "$_trap_line" ] && [ "$_init_line" -lt "$_trap_line" ]; } \ + && ok "init: _UV_OVERRIDE_TMPDIR cleared before exit trap" \ + || bad "init: _UV_OVERRIDE_TMPDIR not cleared before exit trap (init=$_init_line trap=$_trap_line)" + +echo "" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED" From e25e7895a5024b3545d22b334c00b468b0f28141 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 25 Jun 2026 02:56:25 +0200 Subject: [PATCH 103/306] Polish Studio desktop chrome (#6332) * Polish Studio desktop chrome * Fix desktop chrome chat header overlap * Blend desktop titlebar with sidebar * Refine desktop chrome alignment * Fix desktop chrome review items * Reserve mac sidebar chrome space * Fix mac chrome review items * Polish macOS desktop chrome * Align macOS desktop chrome controls * Lower macOS traffic lights * Remove mac sidebar logo from chrome row * Match Tauri update banner styling * Update Tauri updater public key * Fix Tauri startup screen spacing * Work around AppImage WebKitGTK blank screen * Mark Linux AppImage as experimental * Address true desktop chrome review issues * Fix remaining desktop chrome review issues * Fix desktop titlebar inset review issues * Refresh desktop platform after backend auth --- .github/workflows/release-desktop.yml | 15 +- studio/frontend/src/app/provider.tsx | 171 ++++++++++--- studio/frontend/src/app/routes/__root.tsx | 4 +- .../frontend/src/components/app-sidebar.tsx | 242 +++++++++++------- .../src/components/assistant-ui/thread.tsx | 4 +- studio/frontend/src/components/navbar.tsx | 13 +- .../src/components/tauri/startup-screen.tsx | 6 +- .../src/components/tauri/update-banner.tsx | 127 +++++++-- .../src/components/tauri/update-screen.tsx | 4 +- .../src/components/tauri/window-titlebar.tsx | 183 +++++++++---- .../frontend/src/features/chat/chat-page.tsx | 53 ++-- .../src/features/chat/chat-settings-sheet.tsx | 9 +- .../src/features/settings/tabs/about-tab.tsx | 46 ++-- studio/src-tauri/icons/128x128.png | Bin 9194 -> 10181 bytes studio/src-tauri/icons/32x32.png | Bin 1930 -> 2065 bytes studio/src-tauri/icons/icon.icns | Bin 263568 -> 311926 bytes studio/src-tauri/icons/icon.ico | Bin 34589 -> 38034 bytes studio/src-tauri/icons/icon.png | Bin 46007 -> 42705 bytes studio/src-tauri/src/main.rs | 24 ++ studio/src-tauri/tauri.conf.json | 12 +- 20 files changed, 649 insertions(+), 264 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index e747605322..884ff02d11 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -438,6 +438,12 @@ jobs: if (/\brpm\b|\.rpm/i.test(body)) { throw new Error('Desktop release body must not advertise RPM packages'); } + if (/AppImage.*universal|universal.*AppImage/i.test(body)) { + throw new Error('Desktop release body must not advertise AppImage as universal'); + } + if (!/AppImage.*experimental/i.test(body)) { + throw new Error('Desktop release body must mark AppImage as experimental'); + } } JS @@ -580,9 +586,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} @@ -611,9 +618,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} @@ -643,9 +651,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 802f22e21e..914abbbf1d 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -6,6 +6,7 @@ import { UpdateBanner } from "@/components/tauri/update-banner"; import { UpdateScreen } from "@/components/tauri/update-screen"; import { WindowTitlebar, + shouldUseNativeMacWindowTitlebar, shouldUseCustomWindowTitlebar, } from "@/components/tauri/window-titlebar"; import { Toaster } from "@/components/ui/sonner"; @@ -18,9 +19,16 @@ import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend"; import { useTauriUpdate } from "@/hooks/use-tauri-update"; import { isTauri } from "@/lib/api-base"; +import { fetchDeviceType } from "@/config/env"; import { useRouterState } from "@tanstack/react-router"; import { ThemeProvider } from "next-themes"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; interface AppProviderProps { children: ReactNode; @@ -31,18 +39,43 @@ type WindowLayoutGuard = () => boolean; const MIN_WINDOW_WIDTH = 900; const MIN_WINDOW_HEIGHT = 600; +const SETUP_WINDOW_WIDTH = 760; +const SETUP_WINDOW_HEIGHT = 560; async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { - const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window"); if (!isCurrent()) return; const win = getCurrentWindow(); + await win.setResizable(false); + if (!isCurrent()) return; + await win.setSize(new LogicalSize(SETUP_WINDOW_WIDTH, SETUP_WINDOW_HEIGHT)); if (!isCurrent()) return; await win.center(); if (!isCurrent()) return; await win.show(); } +async function enforceMinimumWindowSize( + win: Awaited>, + LogicalSize: typeof import("@tauri-apps/api/window")["LogicalSize"], + isCurrent: WindowLayoutGuard, +): Promise { + const [innerSize, scaleFactor] = await Promise.all([ + win.innerSize(), + win.scaleFactor(), + ]); + if (!isCurrent()) return; + + const logicalWidth = Math.round(innerSize.width / scaleFactor); + const logicalHeight = Math.round(innerSize.height / scaleFactor); + const nextWidth = Math.max(logicalWidth, MIN_WINDOW_WIDTH); + const nextHeight = Math.max(logicalHeight, MIN_WINDOW_HEIGHT); + if (nextWidth !== logicalWidth || nextHeight !== logicalHeight) { + await win.setSize(new LogicalSize(nextWidth, nextHeight)); + } +} + async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window"); const { invoke } = await import("@tauri-apps/api/core"); @@ -91,6 +124,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise // Apply constraints after restore/show: doing so before plugin restore can emit // a Resized event and overwrite the plugin's cached saved size. await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT }); + if (!isCurrent()) return; + await enforceMinimumWindowSize(win, LogicalSize, isCurrent); } async function showWindowFallback(): Promise { @@ -123,7 +158,13 @@ function getTauriWindowMode( } } -function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) { +function TauriUpdateLayer({ + isExternalServer, + children, +}: { + isExternalServer: boolean; + children?: ReactNode; +}) { const update = useTauriUpdate(isExternalServer); const isUpdating = update.status === "updating-backend" || @@ -146,18 +187,22 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) { } return ( - +
+ + {children} +
); } @@ -175,6 +220,35 @@ const WEB_UPDATE_HIDDEN_ROUTES = new Set([ "/signup", ]); +const MAC_NATIVE_CHROME_STYLE = { + "--studio-titlebar-height": "0px", + "--studio-mac-titlebar-height": "34px", + "--studio-mac-traffic-light-inset": "78px", + "--studio-startup-top-inset": "58px", + "--studio-content-top-inset": "0px", + "--studio-non-chat-content-top-inset": "34px", + "--studio-hidden-route-top-inset": "34px", + "--studio-chat-header-height": "44px", + "--studio-chat-header-padding-top": "8px", + "--studio-chat-control-height": "33px", + "--studio-chat-header-right-inset": "0px", +} as CSSProperties; + +const CUSTOM_CHROME_STYLE = { + "--studio-titlebar-height": "0px", + "--studio-custom-titlebar-height": "34px", + "--studio-sidebar-expanded-width": "17.5rem", + "--studio-sidebar-collapsed-width": "3rem", + "--studio-startup-top-inset": "42px", + "--studio-content-top-inset": "34px", + "--studio-hidden-route-top-inset": "34px", + "--studio-chat-header-height": "48px", + "--studio-chat-header-padding-top": "9px", + "--studio-chat-control-height": "33px", + "--studio-chat-header-right-inset": "0px", + "--studio-window-control-inset": "112px", +} as CSSProperties; + function TauriWrapper({ children }: { children: ReactNode }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const { @@ -254,6 +328,11 @@ function TauriWrapper({ children }: { children: ReactNode }) { return () => { disposed = true; }; }, [status, desktopAuthRetry]); + useEffect(() => { + if (!isTauri || status !== "running" || !desktopAuthReady) return; + void fetchDeviceType({ force: true }).catch(() => undefined); + }, [status, desktopAuthReady]); + if (!isTauri) { return ( <> @@ -281,10 +360,19 @@ function TauriWrapper({ children }: { children: ReactNode }) { status === "running" && !desktopAuthReady ? "Signing in to desktop session..." : progressDetail; + const usesCustomTitlebar = shouldUseCustomWindowTitlebar(); + const usesNativeMacTitlebar = shouldUseNativeMacWindowTitlebar(); + const hidesTitlebarSidebar = HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname); const content = showApp ? ( <> - + + + + {children} @@ -305,39 +393,48 @@ function TauriWrapper({ children }: { children: ReactNode }) { /> ); - if (!shouldUseCustomWindowTitlebar()) { + if (!usesCustomTitlebar) { // macOS desktop uses the native titlebar and returns here before the // custom-titlebar branch, so mount the updater banner on this path too. - return ( - <> - {content} -
- - {showApp ? : null} + if (usesNativeMacTitlebar) { + return ( +
+ {(!showApp || hidesTitlebarSidebar) ? ( + - + ); + } + + return ( + <>{content} ); } const showSidebarSurface = - showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname); + showApp && !hidesTitlebarSidebar; return ( -
+
-
+
{content}
-
- - {showApp ? : null} -
); } diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 77ba5788db..e5fa6f0191 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -219,7 +219,7 @@ function RootLayout() { {hideNavbar ? ( -
+
}> @@ -235,7 +235,7 @@ function RootLayout() {
{/* Stays mounted across navigation so an in-flight generation is not cancelled when leaving /chat; hidden (not unmounted) off-route. diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index f563f9ae59..06f2701a16 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -44,7 +44,12 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; +import { + shouldUseCustomWindowTitlebar, + shouldUseNativeMacWindowTitlebar, +} from "@/components/tauri/window-titlebar"; import { cn } from "@/lib/utils"; +import { isTauri } from "@/lib/api-base"; import { useWebUpdateCheck } from "@/hooks/use-web-update-check"; import { Archive03Icon, @@ -272,6 +277,8 @@ function devForceUpdateCard(): boolean { export function AppSidebar() { const t = useT(); const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(); + const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar); + const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); const { pathname, search } = useRouterState({ select: (s) => ({ pathname: s.location.pathname, @@ -458,6 +465,10 @@ export function AppSidebar() { isStudioRoute, ]); + const chatDisabled = trainingInProgress; + const showSidebarBrand = !usesCustomTitlebar; + const showCompactMacBrand = showSidebarBrand && usesNativeMacTitlebar; + function chatSearchForProject(projectId: string | null) { if (projectId) { return { project: projectId }; @@ -983,81 +994,118 @@ export function AppSidebar() { variant="sidebar" className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background" > - - {/* Expanded: compact logo + close toggle */} -
- { - event.preventDefault(); - openNewChat(null); - }} - className="flex items-center gap-[6px] select-none" - aria-label={t("shell.aria.home")} - > - Unsloth - - unsloth - - - {t("shell.beta")} - - - {!isMobile && ( - - - - - - {t("shell.aria.closeSidebar")} - - - )} -
- - {/* Collapsed: panel icon doubles as expand trigger */} - {!isMobile && ( -
- - - - - - {t("shell.aria.openSidebar")} - - -
+ Unsloth + + unsloth + + + {t("shell.beta")} + + + )} + {!isMobile && ( + + + + + + {t("shell.aria.closeSidebar")} + + + )} +
+ {!isMobile && ( +
+ + + + + + {t("shell.aria.openSidebar")} + + +
+ )} + )} {/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */} - + {t("common.help")} - { - // Best-effort server revocation; ignore network errors so - // the local clear still runs and the user lands on /login. - try { - await logout(); - } catch { - clearAuthTokens(); - } - void navigate({ to: "/login" }); - }} - > - - {t("shell.navigation.logOut")} - - setShutdownOpen(true)}> - - {t("common.shutdown")} - + {!isTauri && ( + { + // Best-effort server revocation; ignore network errors so + // the local clear still runs and the user lands on /login. + try { + await logout(); + } catch { + clearAuthTokens(); + } + void navigate({ to: "/login" }); + }} + > + + {t("shell.navigation.logOut")} + + )} + {!isTauri && ( + setShutdownOpen(true)}> + + {t("common.shutdown")} + + )} @@ -1571,11 +1623,13 @@ export function AppSidebar() { - + {!isTauri && ( + + )} { diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index bad9a6b7f3..a05910c29f 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -968,7 +968,9 @@ export const Thread: FC<{ scrollToBottomOnThreadSwitch={false} className={cn( "aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5", - hideComposer ? "pt-4" : "pt-[48px]", + hideComposer + ? "pt-4" + : "pt-[calc(var(--studio-content-top-inset,0px)+48px)]", )} > {!hideWelcome && ( diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 716c9d791f..44387f2480 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -1,13 +1,24 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import { shouldUseNativeMacWindowTitlebar } from "@/components/tauri/window-titlebar"; import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; +import { useState } from "react"; export function Navbar() { const { isMobile } = useSidebar(); + const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); if (!isMobile) { return ( -
+
+ {usesNativeMacTitlebar && ( +
); } return ( diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx index fd67a8a841..678051b36b 100644 --- a/studio/frontend/src/components/tauri/startup-screen.tsx +++ b/studio/frontend/src/components/tauri/startup-screen.tsx @@ -433,12 +433,12 @@ export function StartupScreen({ } return ( -
-
+
+
void; onDismiss: () => void; onCopyDiagnostics: () => Promise; @@ -27,6 +31,11 @@ interface UpdateBannerProps { const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; +function formatVersion(version: string | null | undefined): string { + if (!version) return ""; + return version.startsWith("v") ? version : `v${version}`; +} + export function UpdateBanner({ status, info, @@ -35,6 +44,7 @@ export function UpdateBanner({ isExternalServer = false, updatePolicyMode, manualReleaseUrl, + positioned = true, onInstall, onDismiss, onCopyDiagnostics, @@ -49,6 +59,9 @@ export function UpdateBanner({ const installDisabled = isManualLinuxPackage ? manualReleaseUrl === null : isExternalServer; + const currentVersion = formatVersion(info?.currentVersion); + const latestVersion = formatVersion(info?.version); + const Icon = showFailure ? CircleAlert : Download; async function handleCopyDiagnostics() { setCopying(true); @@ -59,7 +72,10 @@ export function UpdateBanner({ setManualMessage(null); } else { setManualReport(result.report); - setManualMessage(result.error ?? "Clipboard copy failed. Select and copy the diagnostics below."); + setManualMessage( + result.error ?? + "Clipboard copy failed. Select and copy the diagnostics below.", + ); } } catch (error) { setManualReport(null); @@ -73,30 +89,60 @@ export function UpdateBanner({ {show && ( -
+
-
- 🦥 -
-

- {showFailure ? "App update failed" : `New version: v${info?.version}`} +

+