@@ -469,6 +496,7 @@ function TrainingHeaderActions({
stopDialogOpen: boolean;
stopRequested: boolean;
}): ReactElement {
+ const t = useT();
return (
@@ -486,25 +514,25 @@ function TrainingHeaderActions({
disabled={!isTrainingRunning || stopRequested}
>
- {stopRequested ? "Stopping…" : "Stop"}
+ {stopRequested ? t("studio.training.stopping") : t("studio.training.stopAction")}
- Stop Training
+ {t("studio.training.stopTitle")}
- Choose how you want to stop the current training run.
+ {t("studio.training.stopDescription")}
- Continue Training
+ {t("studio.training.continueAction")}
onRequestStop(false)}
>
- Cancel Training
+ {t("studio.training.cancelAction")}
onRequestStop(true)}>
- Stop and Save
+ {t("studio.training.stopAndSave")}
@@ -522,6 +550,7 @@ function MilestoneCallout({
showHalfwayHint: boolean;
onCompareInChat: () => Promise
;
}): ReactElement | null {
+ const t = useT();
if (!(showHalfwayHint || showCompletedHint)) {
return null;
}
@@ -532,7 +561,7 @@ function MilestoneCallout({
{!showCompletedHint && (
- Milestone
+ {t("studio.training.milestone")}
)}
{showCompletedHint
- ? "Training done. Next step: compare base vs fine-tuned outputs."
- : "Halfway done. Training is past 50%."}
+ ? t("studio.training.doneNextStep")
+ : t("studio.training.halfwayDone")}
{!showCompletedHint && (
@@ -555,10 +584,10 @@ function MilestoneCallout({
{showCompletedHint && (
- Compare in Chat
+ {t("studio.training.compareInChat")}
- Export Model
+ {t("studio.training.exportModel")}
)}
diff --git a/studio/frontend/src/features/studio/sections/training-section.tsx b/studio/frontend/src/features/studio/sections/training-section.tsx
index 46f97c358f..adeb1a7b14 100644
--- a/studio/frontend/src/features/studio/sections/training-section.tsx
+++ b/studio/frontend/src/features/studio/sections/training-section.tsx
@@ -28,10 +28,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useRef } from "react";
import { toast } from "@/lib/toast";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
-
-const chartConfig = {
- loss: { label: "Loss", color: "#3b82f6" },
-} satisfies ChartConfig;
+import { useT } from "@/i18n";
const placeholderData = [
{ step: 0, loss: 2.5 },
@@ -43,6 +40,10 @@ const placeholderData = [
];
export function TrainingSection() {
+ const t = useT();
+ const chartConfig = {
+ loss: { label: t("studio.charts.loss"), color: "#3b82f6" },
+ } satisfies ChartConfig;
const store = useTrainingConfigStore();
const { isStarting, startError, startTrainingRun } = useTrainingActions();
const isLoadingModel = store.isLoadingModelDefaults || store.isCheckingVision;
@@ -65,22 +66,39 @@ export function TrainingSection() {
try {
const config = parseYamlConfig(reader.result as string);
store.applyConfigPatch(config);
- toast.success("Config loaded", { description: file.name });
+ toast.success(t("studio.training.configLoaded"), { description: file.name });
} catch (err) {
- toast.error("Failed to load config", {
+ toast.error(t("studio.training.failedToLoadConfig"), {
description:
- err instanceof Error ? err.message : "Invalid YAML file",
+ err instanceof Error ? err.message : t("studio.training.invalidYamlFile"),
});
}
};
reader.onerror = () => {
- toast.error("Failed to read file");
+ toast.error(t("studio.training.failedToReadFile"));
};
reader.readAsText(file);
};
const handleSaveConfig = () => {
- const yamlStr = serializeConfigToYaml(store, store.isVisionModel);
+ // isDatasetImage is null in three windows: before a dataset check
+ // completes, after dataset edits, and on import. Treat all three as
+ // "save it" so the user's choice is never silently dropped while we
+ // wait to confirm the dataset type. Only a confirmed text-only dataset
+ // (=== false) suppresses the vision fields.
+ const includeVisionFields =
+ store.isVisionModel && store.isDatasetImage !== false;
+ // DeepSeek OCR ignores vision_image_size; don't emit it to YAML either,
+ // or a later import on a non-DeepSeek model would activate the stale value.
+ const selectedModelLower = (store.selectedModel ?? "").toLowerCase();
+ const isDeepseekOcr =
+ selectedModelLower.includes("deepseek") &&
+ selectedModelLower.includes("ocr");
+ const yamlStr = serializeConfigToYaml(
+ store,
+ includeVisionFields,
+ includeVisionFields && !isDeepseekOcr,
+ );
const blob = new Blob([yamlStr], { type: "text/yaml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -98,15 +116,15 @@ export function TrainingSection() {
const handleResetConfig = () => {
store.resetToModelDefaults();
- toast.success("Parameters reset to model defaults");
+ toast.success(t("studio.training.parametersReset"));
};
return (
}
- title="Training"
- description="Monitor and control training"
+ title={t("studio.training.title")}
+ description={t("studio.training.description")}
accent="blue"
className={hasMessage ? "min-h-studio-config-column" : "h-studio-config-column"}
>
@@ -147,10 +165,10 @@ export function TrainingSection() {
className="size-5 text-muted-foreground/50"
/>
- No training data yet
+ {t("studio.training.chartNoDataTitle")}
- Start training to see loss progress
+ {t("studio.training.chartNoDataDescription")}
@@ -163,7 +181,13 @@ export function TrainingSection() {
disabled={isStarting || isIncompatible || store.isCheckingDataset || isLoadingModel || !configValidation.ok}
>
- {isStarting ? "Starting..." : isLoadingModel ? "Loading model..." : store.isCheckingDataset ? "Checking dataset..." : "Start Training"}
+ {isStarting
+ ? t("studio.training.starting")
+ : isLoadingModel
+ ? t("studio.training.loadingModel")
+ : store.isCheckingDataset
+ ? t("studio.training.checkingDataset")
+ : t("studio.training.startTraining")}
{startError && (
{startError}
@@ -171,8 +195,8 @@ export function TrainingSection() {
{isIncompatible && (
{!store.isAudioModel && store.isDatasetAudio === true
- ? "This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset."
- : "Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset."}
+ ? t("studio.training.audioIncompatible")
+ : t("studio.training.visionIncompatible")}
)}
{!configValidation.ok && configValidation.message && !isIncompatible && (
@@ -180,7 +204,7 @@ export function TrainingSection() {
)}
{/* Upload / Save / Reset */}
-
Training Config
+
{t("studio.training.configLabel")}
@@ -191,10 +215,10 @@ export function TrainingSection() {
onClick={() => fileInputRef.current?.click()}
>
- Upload
+ {t("studio.training.upload")}
- Load a saved YAML config
+ {t("studio.training.uploadConfigTooltip")}
@@ -206,10 +230,10 @@ export function TrainingSection() {
onClick={handleSaveConfig}
>
- Save
+ {t("studio.training.save")}
- Download current config as YAML
+ {t("studio.training.saveConfigTooltip")}
@@ -221,10 +245,10 @@ export function TrainingSection() {
disabled={!store.selectedModel}
>
- Reset
+ {t("studio.training.reset")}
- Reset to model defaults
+ {t("studio.training.resetConfigTooltip")}
state.isTrainingRunning);
@@ -120,10 +122,13 @@ export function StudioPage(): ReactElement {
}
const subtitle = (() => {
- if (activeTab === "current-run") return runtimeMessage || "Training in progress";
+ if (activeTab === "current-run")
+ return runtimeMessage || t("studio.subtitles.trainingInProgress");
if (activeTab === "history")
- return selectedHistoryRunId ? "Viewing past run" : "View past training runs";
- return "Configure and start training";
+ return selectedHistoryRunId
+ ? t("studio.subtitles.viewingPastRun")
+ : t("studio.subtitles.viewPastRuns");
+ return t("studio.subtitles.configure");
})();
return (
@@ -150,14 +155,14 @@ export function StudioPage(): ReactElement {
- Fine-tuning Studio
+ {t("studio.title")}
{subtitle}
{!hasHydratedRuntime && isHydratingRuntime ? (
- Loading training runtime...
+ {t("studio.loadingRuntime")}
) : (
@@ -168,19 +173,19 @@ export function StudioPage(): ReactElement {
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => setSelectedHistoryRunId(null)}
- aria-label="Back to history"
+ aria-label={t("studio.backToHistory")}
>
)}
- Configure
+ {t("studio.tabs.configure")}
- Current Run
+ {t("studio.tabs.currentRun")}
- History
+ {t("studio.tabs.history")}
diff --git a/studio/frontend/src/features/studio/training-start-overlay.tsx b/studio/frontend/src/features/studio/training-start-overlay.tsx
index 27c5a13163..8ecd3bb7e9 100644
--- a/studio/frontend/src/features/studio/training-start-overlay.tsx
+++ b/studio/frontend/src/features/studio/training-start-overlay.tsx
@@ -33,6 +33,7 @@ import {
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState, type ReactElement } from "react";
+import { useT } from "@/i18n";
const HF_REPO_REGEX = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
@@ -171,6 +172,7 @@ type DownloadRowProps = {
};
function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
+ const t = useT();
// Compute a rolling-window rate + ETA from the same cumulative-byte
// series the poll hook already produces, so we can show
// "5.2 / 20.7 GB • 85.3 MB/s • 3m 12s left" instead of just the pair.
@@ -179,22 +181,25 @@ function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
if (state.downloadedBytes <= 0 && !state.cachePath) return null;
const isComplete = state.totalBytes > 0 && state.percent >= 100;
const statusLabel = isComplete
- ? "Ready"
+ ? t("studio.trainingStart.ready")
: state.totalBytes > 0
- ? "Downloading"
+ ? t("studio.trainingStart.downloading")
: state.downloadedBytes === 0
- ? "Preparing"
+ ? t("studio.trainingStart.preparing")
: null;
const showRate = stats.stable && !isComplete;
const rateSuffix = showRate ? ` • ${formatRate(stats.rateBytesPerSecond)}` : "";
const etaStr =
showRate && state.totalBytes > 0 ? formatEta(stats.etaSeconds) : "--";
- const etaSuffix = etaStr !== "--" ? ` • ${etaStr} left` : "";
+ const etaSuffix =
+ etaStr !== "--" ? ` • ${t("studio.trainingStart.left", { eta: etaStr })}` : "";
const sizeLabel =
state.totalBytes > 0
? `${formatBytes(state.downloadedBytes)} / ${formatBytes(state.totalBytes)}${rateSuffix}${etaSuffix}`
: state.downloadedBytes > 0
- ? `${formatBytes(state.downloadedBytes)} downloaded${rateSuffix}`
+ ? `${t("studio.trainingStart.downloaded", {
+ size: formatBytes(state.downloadedBytes),
+ })}${rateSuffix}`
: null;
return (
@@ -245,6 +250,7 @@ export function TrainingStartOverlay({
message,
currentStep,
}: TrainingStartOverlayProps): ReactElement {
+ const t = useT();
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
const phase = useTrainingRuntimeStore((s) => s.phase);
@@ -273,8 +279,8 @@ export function TrainingStartOverlay({
: null;
const displayMessage =
startFromResume && !isDownloadPhase && /^download/i.test(message)
- ? "Resuming training..."
- : message || "starting training...";
+ ? t("studio.trainingStart.resumingTraining")
+ : message || t("studio.trainingStart.startingTraining");
const rawModelDownload = useModelDownloadProgress(modelName);
const rawDatasetDownload = useDatasetDownloadProgress(datasetName);
const modelDownload = isDownloadPhase
@@ -297,7 +303,7 @@ export function TrainingStartOverlay({
@@ -313,13 +319,13 @@ export function TrainingStartOverlay({
- Cancel Training
+ {t("studio.training.cancelTitle")}
- Do you want to cancel the current training run?
+ {t("studio.training.cancelDescription")}
- Continue Training
+ {t("studio.training.continueAction")}
{
@@ -335,7 +341,7 @@ export function TrainingStartOverlay({
});
}}
>
- Cancel Training
+ {t("studio.training.cancelAction")}
@@ -348,24 +354,27 @@ export function TrainingStartOverlay({
duration={36}
className="bg-gradient-to-r from-emerald-300 via-lime-300 to-teal-300 bg-clip-text font-semibold text-transparent"
>
- {"> unsloth training starts..."}
+ {t("studio.trainingStart.terminalStart")}
{`==((====))==\n \\\\ /|\nO^O/ \\_/ \\\n\\ /\n "-____-"`}
- {"> Preparing model and dataset..."}
+ {t("studio.trainingStart.preparingResources")}
- {"> We are getting everything ready for your run..."}
+ {t("studio.trainingStart.gettingReady")}
- {`> ${displayMessage} | waiting for first step... (${currentStep})`}
+ {t("studio.trainingStart.waitingForFirstStep", {
+ message: displayMessage,
+ step: currentStep,
+ })}
{datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? (
@@ -373,7 +382,7 @@ export function TrainingStartOverlay({
{modelDownload.downloadedBytes > 0 || modelDownload.cachePath ? (
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts
index 5d81f0df9c..1223888b11 100644
--- a/studio/frontend/src/features/training/api/mappers.ts
+++ b/studio/frontend/src/features/training/api/mappers.ts
@@ -23,7 +23,12 @@ export function buildTrainingStartPayload(
const isCpt = config.trainingMethod === "cpt";
const adapterMethod = config.trainingMethod !== "full";
const isQloraMethod = config.trainingMethod === "qlora";
- const isFourBitModel = (config.selectedModel ?? "").toLowerCase().includes("4bit");
+ const _selectedModelLower = (config.selectedModel ?? "").toLowerCase();
+ const isFourBitModel = _selectedModelLower.includes("4bit");
+ // DeepSeek OCR ignores user-selected image size; do not send it.
+ const isDeepseekOcr =
+ _selectedModelLower.includes("deepseek") &&
+ _selectedModelLower.includes("ocr");
const isEmbedding = config.isEmbeddingModel;
const isRawText = isRawTextDatasetFormat(config.datasetFormat);
const hfDataset = config.datasetSource === "huggingface" ? config.dataset : null;
@@ -55,6 +60,10 @@ export function buildTrainingStartPayload(
hf_token: config.hfToken.trim() || null,
load_in_4bit: (adapterMethod && isQloraMethod) || (isCpt && isFourBitModel),
max_seq_length: config.contextLength,
+ vision_image_size:
+ config.isVisionModel && config.isDatasetImage === true && !isDeepseekOcr
+ ? config.visionImageSize
+ : null,
trust_remote_code: config.trustRemoteCode ?? false,
hf_dataset: hfDataset,
subset: hfDataset ? config.datasetSubset : null,
diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts
index e512b9e28d..8fecfaf7b8 100644
--- a/studio/frontend/src/features/training/api/models-api.ts
+++ b/studio/frontend/src/features/training/api/models-api.ts
@@ -27,6 +27,7 @@ interface BackendTrainingDefaults {
eval_steps?: number;
weight_decay?: number;
random_seed?: number;
+ vision_image_size?: number | string | null;
packing?: boolean;
train_on_completions?: boolean;
gradient_checkpointing?: "none" | "true" | "unsloth";
diff --git a/studio/frontend/src/features/training/index.ts b/studio/frontend/src/features/training/index.ts
index 83d8edba75..654ddedd03 100644
--- a/studio/frontend/src/features/training/index.ts
+++ b/studio/frontend/src/features/training/index.ts
@@ -9,7 +9,10 @@ export {
export { useTrainingActions } from "./hooks/use-training-actions";
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
-export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
+export {
+ removeTrainingUnloadGuard,
+ useTrainingUnloadGuard,
+} from "./hooks/use-training-unload-guard";
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
diff --git a/studio/frontend/src/features/training/lib/model-defaults.ts b/studio/frontend/src/features/training/lib/model-defaults.ts
index 8bc9c4e064..3a0a14b150 100644
--- a/studio/frontend/src/features/training/lib/model-defaults.ts
+++ b/studio/frontend/src/features/training/lib/model-defaults.ts
@@ -28,6 +28,7 @@ type ModelDefaultsPatch = Partial<
| "trainOnCompletions"
| "gradientCheckpointing"
| "randomSeed"
+ | "visionImageSize"
| "enableWandb"
| "wandbProject"
| "enableTensorboard"
@@ -129,6 +130,25 @@ export function mapBackendModelConfigToTrainingPatch(
const randomSeed = toNumber(training?.random_seed);
if (randomSeed !== undefined) patch.randomSeed = randomSeed;
+ // Only patch when the config carries the key; model-switch reset lives in
+ // setSelectedModel so same-model reloads don't wipe a user's choice.
+ if (Object.hasOwn(training ?? {}, "vision_image_size")) {
+ const raw = training?.vision_image_size;
+ if (raw == null) {
+ patch.visionImageSize = null;
+ } else {
+ // Mirror studio/backend/models/training.py:_check_vision_image_size:
+ // drop anything outside [_MIN_VISION_IMAGE_SIZE, _MAX_VISION_IMAGE_SIZE]
+ // so the store/UI never show a value the backend would reject.
+ const n = toNumber(raw);
+ if (n !== undefined && Number.isInteger(n) && n >= 256 && n <= 2048) {
+ patch.visionImageSize = n;
+ } else {
+ patch.visionImageSize = null;
+ }
+ }
+ }
+
const packing = toBoolean(training?.packing);
if (packing !== undefined) patch.packing = packing;
diff --git a/studio/frontend/src/features/training/lib/yaml-config.ts b/studio/frontend/src/features/training/lib/yaml-config.ts
index d168da5347..1deabf3547 100644
--- a/studio/frontend/src/features/training/lib/yaml-config.ts
+++ b/studio/frontend/src/features/training/lib/yaml-config.ts
@@ -27,8 +27,27 @@ export function parseYamlConfig(text: string): BackendModelConfig {
console.warn("Ignored unknown YAML keys:", unknownKeys.join(", "));
}
+ // File import is authoritative: forge vision_image_size = null when the
+ // training section is missing, malformed, or missing the key, so a stale
+ // store value cannot survive an import. (Same-model defaults reloads
+ // preserve user choice via Object.hasOwn in model-defaults.ts.)
+ const rawTraining = raw.training;
+ const isPlainTrainingObject =
+ rawTraining != null &&
+ typeof rawTraining === "object" &&
+ !Array.isArray(rawTraining);
+ let trainingObj: Record
;
+ if (!isPlainTrainingObject) {
+ trainingObj = { vision_image_size: null };
+ } else {
+ trainingObj = { ...(rawTraining as Record) };
+ if (!Object.hasOwn(trainingObj, "vision_image_size")) {
+ trainingObj.vision_image_size = null;
+ }
+ }
+
return {
- training: (raw.training ?? undefined) as BackendModelConfig["training"],
+ training: trainingObj as BackendModelConfig["training"],
lora: (raw.lora ?? undefined) as BackendModelConfig["lora"],
logging: (raw.logging ?? undefined) as BackendModelConfig["logging"],
};
@@ -41,6 +60,7 @@ export function parseYamlConfig(text: string): BackendModelConfig {
export function serializeConfigToYaml(
state: TrainingConfigState,
includeVisionFields: boolean,
+ includeVisionImageSize: boolean = includeVisionFields,
): string {
const lora: Record = {
lora_r: state.loraRank,
@@ -58,25 +78,31 @@ export function serializeConfigToYaml(
lora.finetune_mlp_modules = state.finetuneMLPModules;
}
+ const training: Record = {
+ max_seq_length: state.contextLength,
+ num_epochs: state.epochs,
+ learning_rate: state.learningRate,
+ batch_size: state.batchSize,
+ gradient_accumulation_steps: state.gradientAccumulation,
+ warmup_steps: state.warmupSteps,
+ max_steps: state.maxSteps,
+ save_steps: state.saveSteps,
+ eval_steps: state.evalSteps,
+ weight_decay: state.weightDecay,
+ random_seed: state.randomSeed,
+ packing: state.packing,
+ train_on_completions: state.trainOnCompletions,
+ gradient_checkpointing: state.gradientCheckpointing,
+ optim: state.optimizerType,
+ lr_scheduler_type: state.lrSchedulerType,
+ };
+
+ if (includeVisionImageSize) {
+ training.vision_image_size = state.visionImageSize;
+ }
+
const config = {
- training: {
- max_seq_length: state.contextLength,
- num_epochs: state.epochs,
- learning_rate: state.learningRate,
- batch_size: state.batchSize,
- gradient_accumulation_steps: state.gradientAccumulation,
- warmup_steps: state.warmupSteps,
- max_steps: state.maxSteps,
- save_steps: state.saveSteps,
- eval_steps: state.evalSteps,
- weight_decay: state.weightDecay,
- random_seed: state.randomSeed,
- packing: state.packing,
- train_on_completions: state.trainOnCompletions,
- gradient_checkpointing: state.gradientCheckpointing,
- optim: state.optimizerType,
- lr_scheduler_type: state.lrSchedulerType,
- },
+ training,
lora,
};
diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts
index ef16f641f5..137669f8b8 100644
--- a/studio/frontend/src/features/training/stores/training-config-store.ts
+++ b/studio/frontend/src/features/training/stores/training-config-store.ts
@@ -390,6 +390,8 @@ export const useTrainingConfigStore = create()(
error instanceof Error
? error.message
: "Failed to load model defaults",
+ // Defaults load failed; reset so no prior model's value lingers.
+ visionImageSize: DEFAULT_HYPERPARAMS.visionImageSize,
});
// Fallback vision check if config endpoint fails.
@@ -498,7 +500,16 @@ export const useTrainingConfigStore = create()(
},
setSelectedModel: (selectedModel) => {
const previousModel = get().selectedModel;
- set({ selectedModel, modelDefaultsError: null });
+ // Reset vision_image_size on a true switch only; same-model reloads
+ // go through the mapper, which preserves the user's choice.
+ const patch: { selectedModel: string | null; modelDefaultsError: null; visionImageSize?: number | null } = {
+ selectedModel,
+ modelDefaultsError: null,
+ };
+ if (selectedModel !== previousModel) {
+ patch.visionImageSize = DEFAULT_HYPERPARAMS.visionImageSize;
+ }
+ set(patch);
if (!selectedModel) {
_modelConfigController?.abort();
@@ -701,6 +712,7 @@ export const useTrainingConfigStore = create()(
}),
setEpochs: (epochs) => set({ epochs }),
setContextLength: (contextLength) => set({ contextLength }),
+ setVisionImageSize: (visionImageSize) => set({ visionImageSize }),
setLearningRate: (learningRate) => {
_learningRateManuallySet = true;
set({ learningRate });
@@ -755,7 +767,10 @@ export const useTrainingConfigStore = create()(
resetToModelDefaults: () => {
const { selectedModel } = get();
if (!selectedModel) return;
- set({ modelDefaultsAppliedFor: null });
+ set({
+ modelDefaultsAppliedFor: null,
+ visionImageSize: DEFAULT_HYPERPARAMS.visionImageSize,
+ });
loadAndApplyModelDefaults(selectedModel);
},
applyConfigPatch: (config: BackendModelConfig) => {
diff --git a/studio/frontend/src/features/training/types/api.ts b/studio/frontend/src/features/training/types/api.ts
index 0cb881e634..5c12e71186 100644
--- a/studio/frontend/src/features/training/types/api.ts
+++ b/studio/frontend/src/features/training/types/api.ts
@@ -7,6 +7,7 @@ export interface TrainingStartRequest {
hf_token: string | null;
load_in_4bit: boolean;
max_seq_length: number;
+ vision_image_size?: number | null;
/** Allow loading models with custom code. Only enable for repos you trust. */
trust_remote_code?: boolean;
hf_dataset: string | null;
diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts
index 5b156316ca..f40f053e8b 100644
--- a/studio/frontend/src/features/training/types/config.ts
+++ b/studio/frontend/src/features/training/types/config.ts
@@ -82,6 +82,7 @@ export interface TrainingConfigState {
finetuneMLPModules: boolean;
targetModules: string[];
maxPositionEmbeddings: number | null;
+ visionImageSize: number | null;
}
export interface TrainingConfigActions {
@@ -115,6 +116,7 @@ export interface TrainingConfigActions {
setUploadedEvalFile: (file: string | null) => void;
setEpochs: (epochs: number) => void;
setContextLength: (length: number) => void;
+ setVisionImageSize: (size: number | null) => void;
setLearningRate: (rate: number) => void;
setEmbeddingLearningRate: (rate: number | null) => void;
setOptimizerType: (value: string) => void;
diff --git a/studio/frontend/src/i18n/AGENTS.md b/studio/frontend/src/i18n/AGENTS.md
new file mode 100644
index 0000000000..42d964acca
--- /dev/null
+++ b/studio/frontend/src/i18n/AGENTS.md
@@ -0,0 +1,11 @@
+# i18n Contribution Instructions
+
+- `locales/en.ts` is the complete baseline message file.
+- Non-English locale files may be partial. Missing keys must fall back to English at runtime.
+- Use BCP 47 locale tags for new languages, for example `zh-CN`, `ja-JP`, and `ko-KR`.
+- Do not change fallback logic to hide missing translations.
+- Do not add automatic DOM translation, MutationObserver text replacement, or runtime guess-based translation.
+- Preserve interpolation variables exactly, for example `{count}`, `{model}`, and `{provider}`.
+- Keep product and technical names unchanged unless there is an established localized name, for example `Unsloth Studio`, `LoRA`, `GGUF`, and `Hugging Face`.
+- Keep translation changes small and reviewable. Prefer separate commits for runtime changes, UI migration, and locale text.
+- When adding user-facing Studio UI text, add the English message key first and add non-English overrides only when the translation is clear.
diff --git a/studio/frontend/src/i18n/check-parity.ts b/studio/frontend/src/i18n/check-parity.ts
new file mode 100644
index 0000000000..9808fd0e36
--- /dev/null
+++ b/studio/frontend/src/i18n/check-parity.ts
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Parity check between en.ts and every non-English locale.
+// - Locale files may be partial; missing keys must fall back to English.
+// - All zh-CN keys must exist in en (no extras).
+// - Placeholder set must match per leaf between en and the overlay.
+//
+// Run: npx tsx src/i18n/check-parity.ts
+
+import { en } from "./locales/en.ts";
+import { zhCN } from "./locales/zh-CN.ts";
+
+type Tree = { readonly [k: string]: string | Tree };
+
+function isTree(v: unknown): v is Tree {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+function placeholders(s: string): string[] {
+ const out: string[] = [];
+ const re = /\{([a-zA-Z0-9_]+)\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(s))) out.push(m[1]);
+ return out.sort();
+}
+
+function checkOverlay(
+ enNode: Tree,
+ overlay: Tree | undefined,
+ path: string,
+ errors: string[],
+ missing: string[],
+): void {
+ for (const [k, v] of Object.entries(enNode)) {
+ const subPath = path ? `${path}.${k}` : k;
+ if (typeof v === "string") {
+ if (overlay === undefined) {
+ missing.push(subPath);
+ continue;
+ }
+ const overlayV = overlay[k];
+ if (overlayV === undefined) {
+ missing.push(subPath);
+ continue;
+ }
+ if (typeof overlayV !== "string") {
+ errors.push(`${subPath} should be string, got ${typeof overlayV}`);
+ continue;
+ }
+ const enP = placeholders(v);
+ const ovP = placeholders(overlayV);
+ if (JSON.stringify(enP) !== JSON.stringify(ovP)) {
+ errors.push(
+ `${subPath}: placeholder mismatch en={${enP.join(",")}} overlay={${ovP.join(",")}}`,
+ );
+ }
+ } else if (isTree(v)) {
+ const overlaySub = overlay === undefined ? undefined : overlay[k];
+ if (overlaySub !== undefined && !isTree(overlaySub)) {
+ errors.push(`${subPath} should be an object, got ${typeof overlaySub}`);
+ continue;
+ }
+ checkOverlay(v, overlaySub, subPath, errors, missing);
+ }
+ }
+}
+
+function checkExtras(
+ overlay: Tree,
+ enNode: Tree,
+ path: string,
+ errors: string[],
+): void {
+ for (const [k, v] of Object.entries(overlay)) {
+ const subPath = path ? `${path}.${k}` : k;
+ if (!(k in enNode)) {
+ errors.push(`${subPath} exists in overlay but not in en`);
+ continue;
+ }
+ const enV = enNode[k];
+ if (isTree(v) && isTree(enV)) {
+ checkExtras(v, enV, subPath, errors);
+ } else if (isTree(v) !== isTree(enV)) {
+ errors.push(`${subPath}: shape mismatch (en=${typeof enV}, overlay=${typeof v})`);
+ }
+ }
+}
+
+const overlays: Record = { "zh-CN": zhCN as unknown as Tree };
+let anyError = false;
+
+for (const [locale, overlay] of Object.entries(overlays)) {
+ const errors: string[] = [];
+ const missing: string[] = [];
+ checkOverlay(en as unknown as Tree, overlay, "", errors, missing);
+ checkExtras(overlay, en as unknown as Tree, "", errors);
+
+ console.log(`\n=== ${locale} ===`);
+ console.log(`Missing keys (will fall back to en): ${missing.length}`);
+ if (errors.length) {
+ anyError = true;
+ console.error(`Errors (${errors.length}):`);
+ for (const e of errors) console.error(` - ${e}`);
+ } else {
+ console.log("No errors.");
+ }
+}
+
+if (anyError) process.exit(1);
+console.log("\nAll locale overlays pass parity.");
diff --git a/studio/frontend/src/i18n/index.ts b/studio/frontend/src/i18n/index.ts
new file mode 100644
index 0000000000..6cd2fe230a
--- /dev/null
+++ b/studio/frontend/src/i18n/index.ts
@@ -0,0 +1,44 @@
+// 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 { useCallback } from "react";
+import { useLocale } from "./locale-store";
+import { translate } from "./messages";
+import type { InterpolationValues } from "./types";
+import type { TranslationKey } from "./messages";
+
+export {
+ DEFAULT_LOCALE,
+ LOCALE_STORAGE_KEY,
+ getLocale,
+ initializeLocale,
+ setLocale,
+ subscribeLocale,
+ useLocale,
+} from "./locale-store";
+export {
+ LOCALES,
+ isSupportedLocale,
+ messages,
+ translate,
+} from "./messages";
+export type { Locale, TranslationKey } from "./messages";
+export type {
+ DeepPartialMessageTree,
+ InterpolationValues,
+ MessageKey,
+ MessageTree,
+} from "./types";
+
+export function useT(): (
+ key: TranslationKey,
+ values?: InterpolationValues,
+) => string {
+ const locale = useLocale();
+
+ return useCallback(
+ (key: TranslationKey, values?: InterpolationValues) =>
+ translate(key, values, locale),
+ [locale],
+ );
+}
diff --git a/studio/frontend/src/i18n/locale-store.ts b/studio/frontend/src/i18n/locale-store.ts
new file mode 100644
index 0000000000..8b4643f5a9
--- /dev/null
+++ b/studio/frontend/src/i18n/locale-store.ts
@@ -0,0 +1,130 @@
+// 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 { useSyncExternalStore } from "react";
+import { isSupportedLocale, type Locale } from "./messages";
+
+export const DEFAULT_LOCALE: Locale = "en";
+export const LOCALE_STORAGE_KEY = "unsloth_locale";
+
+const subscribers = new Set<() => void>();
+
+let currentLocale: Locale = DEFAULT_LOCALE;
+let isStorageListenerActive = false;
+
+function normalizeLocale(value: unknown): Locale {
+ return isSupportedLocale(value) ? value : DEFAULT_LOCALE;
+}
+
+function readStoredLocale(): Locale {
+ try {
+ const stored = globalThis.localStorage?.getItem(LOCALE_STORAGE_KEY) ?? null;
+ return normalizeLocale(stored);
+ } catch {
+ return DEFAULT_LOCALE;
+ }
+}
+
+function writeStoredLocale(locale: Locale): void {
+ try {
+ globalThis.localStorage?.setItem(LOCALE_STORAGE_KEY, locale);
+ } catch {
+ // localStorage 可能被禁用;失败只影响持久化,不影响当前会话语言。
+ }
+}
+
+function syncDocumentLang(locale: Locale): void {
+ if (typeof document === "undefined") return;
+ document.documentElement.lang = locale;
+}
+
+function notifySubscribers(): void {
+ for (const subscriber of subscribers) subscriber();
+}
+
+function updateCurrentLocale(locale: Locale): void {
+ if (locale === currentLocale) return;
+ currentLocale = locale;
+ syncDocumentLang(locale);
+ notifySubscribers();
+}
+
+function isLocaleStorageEvent(event: StorageEvent): boolean {
+ if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) return false;
+ if (!event.storageArea || typeof window === "undefined") return true;
+ // Accessing window.localStorage can throw in privacy-restricted contexts
+ // where storage is blocked; mirror the try/catch in readStoredLocale/
+ // writeStoredLocale so storage-event handling is just as resilient.
+ try {
+ return event.storageArea === window.localStorage;
+ } catch {
+ return false;
+ }
+}
+
+function handleStorageEvent(event: StorageEvent): void {
+ if (!isLocaleStorageEvent(event)) return;
+ const nextLocale =
+ event.key === null ? DEFAULT_LOCALE : normalizeLocale(event.newValue);
+ updateCurrentLocale(nextLocale);
+}
+
+function startStorageListener(): void {
+ if (isStorageListenerActive || typeof window === "undefined") return;
+ window.addEventListener("storage", handleStorageEvent);
+ isStorageListenerActive = true;
+}
+
+function stopStorageListener(): void {
+ if (!isStorageListenerActive || typeof window === "undefined") return;
+ window.removeEventListener("storage", handleStorageEvent);
+ isStorageListenerActive = false;
+}
+
+function getLocaleSnapshot(): Locale {
+ return currentLocale;
+}
+
+function getServerLocaleSnapshot(): Locale {
+ return DEFAULT_LOCALE;
+}
+
+export function subscribeLocale(listener: () => void): () => void {
+ const shouldStartStorageListener = subscribers.size === 0;
+ subscribers.add(listener);
+ if (shouldStartStorageListener) startStorageListener();
+
+ return () => {
+ subscribers.delete(listener);
+ if (subscribers.size === 0) stopStorageListener();
+ };
+}
+
+export function initializeLocale(): Locale {
+ const nextLocale = readStoredLocale();
+ currentLocale = nextLocale;
+ syncDocumentLang(nextLocale);
+ notifySubscribers();
+ return nextLocale;
+}
+
+export function getLocale(): Locale {
+ return currentLocale;
+}
+
+export function setLocale(locale: Locale): void {
+ const requestedLocale = normalizeLocale(locale);
+ writeStoredLocale(requestedLocale);
+
+ currentLocale = requestedLocale;
+ syncDocumentLang(requestedLocale);
+ notifySubscribers();
+}
+
+export function useLocale(): Locale {
+ return useSyncExternalStore(
+ subscribeLocale,
+ getLocaleSnapshot,
+ getServerLocaleSnapshot,
+ );
+}
diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts
new file mode 100644
index 0000000000..b94f3aa105
--- /dev/null
+++ b/studio/frontend/src/i18n/locales/en.ts
@@ -0,0 +1,731 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+export const en = {
+ common: {
+ cancel: "Cancel",
+ close: "Close",
+ delete: "Delete",
+ done: "Done",
+ error: "Error",
+ export: "Export",
+ help: "Help",
+ loading: "Loading...",
+ new: "New",
+ rename: "Rename",
+ save: "Save",
+ search: "Search",
+ shutdown: "Shutdown",
+ },
+ shell: {
+ beta: "BETA",
+ brand: "unsloth",
+ product: "Unsloth Studio",
+ accountMenu: "{name} account menu",
+ aria: {
+ home: "Unsloth home",
+ closeSidebar: "Close sidebar",
+ openSidebar: "Open sidebar",
+ chatOptions: "Chat options",
+ runOptions: "Run options",
+ },
+ navigation: {
+ newChat: "New Chat",
+ compare: "Compare",
+ search: "Search",
+ train: "Train",
+ recipes: "Recipes",
+ export: "Export",
+ recents: "Recents",
+ settings: "Settings",
+ api: "API",
+ lightMode: "Light Mode",
+ darkMode: "Dark Mode",
+ guidedTour: "Guided Tour",
+ help: "Help",
+ logOut: "Log out",
+ shutdown: "Shutdown",
+ },
+ notFound: {
+ title: "Page not found",
+ description: "{path} does not exist.",
+ backToChat: "Back to chat",
+ },
+ dialog: {
+ deleteChat: {
+ title: "Delete chat",
+ description: "Are you sure you want to delete this chat \"{name}\"?",
+ },
+ deleteRun: {
+ title: "Delete training run",
+ description: "Are you sure you want to delete this run \"{name}\"?",
+ },
+ renameChat: {
+ title: "Rename chat",
+ placeholder: "Chat title",
+ },
+ renameRun: {
+ title: "Rename run",
+ placeholder: "Run name",
+ },
+ },
+ toast: {
+ cannotDeleteRunningRun: "Cannot delete a running training run",
+ failedToDeleteChat: "Failed to delete chat",
+ failedToDeleteRun: "Failed to delete run",
+ failedToRenameChat: "Failed to rename chat",
+ failedToRenameRun: "Failed to rename run",
+ },
+ },
+ settings: {
+ title: "Settings",
+ dialog: {
+ title: "Settings",
+ description: "Manage your Unsloth Studio preferences.",
+ closeAriaLabel: "Close settings",
+ },
+ tabs: {
+ general: "General",
+ profile: "Profile",
+ appearance: "Appearance",
+ chat: "Chat",
+ connections: "Connections",
+ apiKeys: "API",
+ about: "Help",
+ },
+ general: {
+ title: "General",
+ description: "Global preferences for Unsloth Studio.",
+ account: "Account",
+ huggingFaceToken: "Hugging Face token",
+ huggingFaceTokenDescription:
+ "Used to load gated models and push artifacts.",
+ hideToken: "Hide token",
+ showToken: "Show token",
+ chatDefaults: "Chat defaults",
+ autoTitleNewChats: "Auto-title new chats",
+ autoTitleNewChatsDescription:
+ "Generate a short title from the first message.",
+ gettingStarted: "Getting started",
+ startOnboarding: "Start onboarding",
+ startOnboardingDescription:
+ "Open the setup wizard again without changing your account.",
+ startOnboardingAction: "Start onboarding",
+ resetPreferences: {
+ sectionTitle: "Danger zone",
+ label: "Reset all local preferences",
+ description:
+ "Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected.",
+ action: "Reset preferences",
+ confirmTitle: "Reset all local preferences?",
+ confirmDescription:
+ "This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed chat settings are not affected.",
+ confirmAction: "Reset and reload",
+ },
+ },
+ profile: {
+ title: "Profile",
+ description: "Update how your profile appears in Studio.",
+ changePicture: "Change profile picture",
+ displayName: "Display name",
+ nameSaved: "Profile name saved",
+ namePersistErrorTitle: "Could not persist profile name",
+ namePersistErrorDescription:
+ "Name updated for this session, but may not persist after reload.",
+ photoUpdated: "Profile photo updated",
+ photoPersistErrorTitle: "Could not persist profile photo",
+ photoPersistErrorDescription:
+ "Photo updated for this session, but may not persist after reload.",
+ photoUpdateErrorTitle: "Could not update profile photo",
+ imageUseError: "Could not use this image.",
+ },
+ appearance: {
+ title: "Appearance",
+ description: "How Unsloth Studio looks on this device.",
+ theme: {
+ title: "Theme",
+ label: "Color scheme",
+ description: "Choose light, dark, or follow your system.",
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+ },
+ language: {
+ title: "Language",
+ label: "Display language",
+ description: "Choose the language used by Studio.",
+ },
+ layout: {
+ title: "Layout",
+ compactSidebar: "Pin sidebar by default",
+ compactSidebarDescription:
+ "Keep the sidebar expanded instead of collapsing to icons.",
+ },
+ },
+ chat: {
+ title: "Chat",
+ description: "Manage your chat history stored on this device.",
+ data: "Data",
+ exportHistory: "Export chat history",
+ exportHistoryDescription:
+ "Download all chats and messages as a JSON file.",
+ exportAction: "Export",
+ exportingAction: "Exporting...",
+ clearHistory: "Clear chat history",
+ clearHistoryDescription: "Delete local chat history from this device.",
+ clearAction: "Clear",
+ clearAllChats: "Clear all chats",
+ clearAllChatsDescription:
+ "Permanently delete every chat on this device.",
+ noChatsToClear: "No chats to clear.",
+ clearOneChatDescription:
+ "Permanently delete the only chat on this device.",
+ clearChatCountDescription:
+ "Permanently delete all {count} chats on this device.",
+ clearChatsAction: "Clear chats",
+ clearOneChatTitle: "Clear 1 chat?",
+ clearChatsTitle: "Clear {count} chats?",
+ clearChatsConfirmDescription:
+ "This permanently deletes every chat and message stored on this device. This cannot be undone.",
+ clearingAction: "Clearing...",
+ clearOneChatAction: "Clear 1 chat",
+ clearChatCountAction: "Clear {count} chats",
+ clearedAllChats: "Cleared all chats",
+ clearedOneChat: "Cleared 1 chat",
+ clearedChatCount: "Cleared {count} chats",
+ someChatsCouldNotBeCleared: "Some chats could not be cleared",
+ chatsClearedRemainOne:
+ "{clearedCount} chats cleared; 1 chat remains. Please retry.",
+ chatsClearedRemain:
+ "{clearedCount} chats cleared; {remainingCount} chats remain. Please retry.",
+ oneChatClearedRemain:
+ "1 chat cleared; {remainingCount} chats remain. Please retry.",
+ oneChatClearedRemainOne:
+ "1 chat cleared; 1 chat remains. Please retry.",
+ storageClearFailedOne:
+ "A storage clear failed; 1 chat may remain. Please retry.",
+ storageClearFailed:
+ "A storage clear failed; {count} chats may remain. Please retry.",
+ failedToClearChats: "Failed to clear chats",
+ },
+ connections: {
+ title: "Connections",
+ description: "Manage providers and external service connections.",
+ },
+ apiKeys: {
+ title: "API",
+ description: "Access Unsloth programmatically via the OpenAI-compatible API.",
+ readDocs: "Read the API docs",
+ noAccess: "No API access yet.",
+ newBadge: "New",
+ accessTokens: "Access tokens",
+ loadError: "Couldn't load API access.",
+ createError: "Couldn't create access token.",
+ revokeError: "Couldn't revoke access token.",
+ never: "Never",
+ tokenNamePlaceholder: "Token name (e.g. production)",
+ newAccessTokenName: "New access token name",
+ createToken: "Create token",
+ creating: "Creating...",
+ newTokenCreated: "New access token created",
+ accessTokenCopied: "Access token copied",
+ copyAccessToken: "Copy access token",
+ copyNow: "Copy now - this won't be shown again.",
+ usageExamples: "Usage examples",
+ usageTools: "Tools",
+ copySnippet: "Copy snippet",
+ copy: "Copy",
+ copied: "Copied",
+ setupDocs: "Setup docs:",
+ relativeNever: "never",
+ relativeJustNow: "just now",
+ relativeHoursAgo: "{count}h ago",
+ relativeDaysAgo: "{count}d ago",
+ relativeMonthsAgo: "{count}mo ago",
+ relativeYearsAgo: "{count}y ago",
+ expired: "expired",
+ today: "today",
+ inDays: "in {count}d",
+ created: "Created {value}",
+ used: "Used {value}",
+ expires: "Expires {value}",
+ actionsFor: "Actions for {name}",
+ copyPrefix: "Copy prefix",
+ revokeToken: "Revoke token",
+ revokeTitle: "Revoke access token \"{name}\"?",
+ revokeDescription:
+ "Applications using this token will immediately lose access. This cannot be undone.",
+ revokeAction: "Revoke \"{name}\"",
+ revoking: "Revoking...",
+ },
+ about: {
+ title: "About",
+ description:
+ "Documentation, release notes, feedback, and Studio build info.",
+ studioVersion: "Studio Version",
+ packageVersion: "Package Version",
+ updates: "Updates",
+ help: "Help",
+ documentation: "Documentation",
+ releaseNotes: "Release notes",
+ whatsNew: "What's new",
+ feedback: "Feedback",
+ reportIssue: "Report an issue",
+ dangerZone: "Danger zone",
+ shutDownStudio: "Shut down Unsloth Studio",
+ shutDownStudioDescription:
+ "Stops the Studio server process and ends your session.",
+ shutDown: "Shut down",
+ update: {
+ title: "Update Unsloth Studio",
+ openPowerShell: "Open PowerShell and run:",
+ openTerminal: "Open Terminal and run:",
+ commandText: "{label} text",
+ copied: "Copied",
+ copyCommand: "Copy command",
+ commandCopied: "{label} copied",
+ copyNamedCommand: "Copy {label}",
+ checkingInstall: "Checking how Studio was installed...",
+ localInstallDetected:
+ "Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
+ pullThenUpdate:
+ "Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
+ gitPullCommand: "git pull command",
+ localUpdateCommand: "local update command",
+ localInstallerFallback:
+ "If the Studio update command is unavailable, run the local installer from that checkout:",
+ localInstallerCommand: "local installer command",
+ sourceInstallDetected:
+ "This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
+ repoCheckoutFallback:
+ "If you still have the Unsloth repo checkout, run the local installer from that checkout:",
+ restartAfterUpdate:
+ "Restart Studio after updating for changes to take effect.",
+ unknownInstall:
+ "Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
+ curlOrPypi: "For curl or PyPI installs, run:",
+ updateCommand: "update command",
+ localCheckout:
+ "For local checkout installs, update from that checkout instead and use the local update command:",
+ fallbackInstruction:
+ "If that fails or unsloth studio update is unavailable, run:",
+ fallbackCommand: "fallback command",
+ },
+ },
+ },
+ studio: {
+ routeTitle: "Train",
+ title: "Fine-tuning Studio",
+ subtitles: {
+ configure: "Configure and start training",
+ trainingInProgress: "Training in progress",
+ viewPastRuns: "View past training runs",
+ viewingPastRun: "Viewing past run",
+ },
+ tabs: {
+ configure: "Configure",
+ currentRun: "Current Run",
+ history: "History",
+ },
+ loadingRuntime: "Loading training runtime...",
+ backToHistory: "Back to history",
+ sections: {
+ model: "Model",
+ dataset: "Dataset",
+ params: "Parameters",
+ training: "Training",
+ charts: "Charts",
+ progress: "Training Progress",
+ },
+ configure: {
+ title: "Configure",
+ description: "Choose a model, dataset, and training settings.",
+ startTraining: "Start Training",
+ starting: "Starting...",
+ loadingModel: "Loading model...",
+ checkingDataset: "Checking dataset...",
+ trainingConfig: "Training Config",
+ },
+ model: {
+ title: "Model",
+ description: "Select base model and training method",
+ fasterTrainingBadge: "2x Faster Training",
+ baseModel: "Base model",
+ localModel: "Local Model",
+ localModelTooltip: "Path to a locally downloaded model or a custom HF repo.",
+ scanningLocalAndCachedModels: "Scanning local and cached models...",
+ scanning: "Scanning...",
+ scanningLocalModels: "Scanning local models...",
+ noLocalModelsFound: "No local models found",
+ noLocalModelsFoundManual: "No local models found. Enter path manually.",
+ failedToLoadLocalModels: "Failed to load local models",
+ hfCache: "HF cache",
+ customFolders: "Custom Folders",
+ localDir: "Local dir",
+ huggingFaceModel: "Hugging Face Model",
+ huggingFaceModelTooltip:
+ "Search Hugging Face models or pick from our recommended list.",
+ searchModels: "Search models...",
+ searching: "Searching...",
+ noModelsFound: "No models found",
+ needsVram: "Needs ~{vram}GB VRAM (GPU: {gpu}GB)",
+ tightVram: "~{vram}GB VRAM (tight fit on {gpu}GB)",
+ vramEstimate: "~{vram}GB VRAM",
+ method: "Method",
+ methodTooltip:
+ "QLoRA uses 4-bit quantization for lowest VRAM. LoRA uses 16-bit. Full updates all weights. CPT (Continued Pretraining) trains on raw text to adapt the model to a new domain without chat formatting.",
+ readMore: "Read more",
+ fullFineTune: "Full Fine-tune",
+ checkingToken: "Checking token...",
+ getOrUpdateToken: "Get or update token",
+ huggingFaceTokenOptional: "Hugging Face Token (Optional)",
+ continuedPretraining: "Continued Pretraining",
+ localModels: "Local models",
+ localModelsFound: "{count} local/cached models found",
+ loadingLocalModels: "Loading local models...",
+ },
+ dataset: {
+ title: "Dataset",
+ description: "Select or upload training data",
+ source: "Dataset source",
+ chooseDataset: "Choose dataset",
+ chooseDatasetTooltip:
+ "Use the popup tabs to switch between Hugging Face and local recipe outputs.",
+ localTab: "Local",
+ searchHuggingFaceDatasets: "Search Hugging Face datasets...",
+ searchLocalDatasets: "Search local datasets...",
+ searching: "Searching...",
+ noDatasetsFound: "No datasets found",
+ loadingLocalDatasets: "Loading local datasets...",
+ failedToLoadLocalDatasets: "Failed to load local datasets.",
+ noLocalDatasetsYet: "No local datasets yet.",
+ noLocalDatasetsMatchSearch: "No local datasets match search.",
+ openDataRecipes: "Open Data Recipes",
+ browsingSource:
+ "Browsing {browsing}. Current selection stays {current}.",
+ localDatasets: "Local datasets",
+ localDataset: "Local dataset",
+ localDatasetRows: " / {count} rows",
+ huggingFaceDataset: "Hugging Face Dataset",
+ localDatasetMetadata: "Local dataset metadata",
+ dataRecipeOutput: "Data Recipe output.",
+ rows: "Rows",
+ columns: "Columns",
+ batches: "Batches",
+ updated: "Updated",
+ evalDataset: "Eval dataset",
+ uploading: "Uploading...",
+ upload: "Upload",
+ uploadEvalFile: "Upload eval file",
+ evalDatasetDescription:
+ "Optional. If not provided, a small portion will be split from the training data.",
+ advanced: "Advanced",
+ targetFormat: "Target Format",
+ targetFormatTooltip:
+ "Format of your training data. Auto-detect works for most datasets.",
+ auto: "Auto",
+ rawText: "Raw Text",
+ trainSplitStart: "Train Split Start",
+ trainSplitStartTooltip:
+ "Only train on a subset of your training split by specifying a start row index (inclusive, 0-based). Leave empty to start from the first row.",
+ trainSplitEnd: "Train Split End",
+ trainSplitEndTooltip:
+ "Last row index to include from the training split (inclusive, 0-based). For example, set Start to 0 and End to 99 to train on the first 100 rows. Leave empty to use all remaining rows.",
+ endPlaceholder: "End",
+ clear: "Clear",
+ dropFileOrClick: "Drop 1 file here or click to upload",
+ viewDataset: "View dataset",
+ uploadFailed: "Upload failed",
+ unknownError: "Unknown error",
+ unsupportedFileType: "Unsupported file type",
+ uploadOneFileType: "Upload one {types} file.",
+ datasetUploaded: "Dataset uploaded",
+ evalDatasetUploaded: "Eval dataset uploaded",
+ uploadOneFileAtATime: "Upload one file at a time",
+ uploadSingleFileDescription:
+ "Training dataset upload accepts a single file.",
+ checkingToken: "Checking token...",
+ getOrUpdateToken: "Get or update token",
+ preview: "Preview dataset",
+ split: "Split",
+ subset: "Subset",
+ },
+ params: {
+ title: "Parameters",
+ description: "Configure training hyperparameters",
+ loraSettings: "LoRA Settings",
+ trainingHyperparameters: "Training Hyperparameters",
+ maxSteps: "Max Steps",
+ epochs: "Epochs",
+ useMaxSteps: "Use Max Steps",
+ useEpochs: "Use Epochs",
+ maxStepsTooltip: "Override total optimizer steps.",
+ epochsTooltip: "Number of full passes over the dataset.",
+ epochsDescription: "Each epoch is one full pass over your dataset.",
+ maxStepsDescription: "Limits training to a fixed number of optimizer steps.",
+ contextLength: "Context Length",
+ contextLengthTooltip: "Maximum number of tokens per training sample.",
+ customContextLength: "Enter a custom value",
+ contextLengthDescription: "Max sequence length for training samples",
+ learningRate: "Learning Rate",
+ learningRateTooltip:
+ "Step size for weight updates. Lower values train slower but more stably.",
+ learningRateDescription:
+ "Recommended: 2e-4 for LoRA, 5e-5 for CPT, 2e-5 for full fine-tune",
+ embeddingLearningRate: "Embedding Learning Rate",
+ embeddingLearningRateTooltip:
+ "Only used when CPT is training embed_tokens. Embeddings are easier to destabilize than LoRA weights, so they usually need a smaller LR. Leave blank to use lr/10; typical working range is 2x-10x smaller than the main LR. Increase it only if vocabulary or domain-token adaptation is too slow.",
+ embeddingLearningRateDescription:
+ "Leave blank to use lr/10 (recommended). Typical range is 2x-10x smaller than the main learning rate.",
+ rank: "Rank",
+ rankTooltip: "Dimension of the low-rank matrices. Higher = more capacity.",
+ alpha: "Alpha",
+ alphaTooltip: "Scaling factor for LoRA updates. Usually 2x rank.",
+ dropout: "Dropout",
+ dropoutTooltip: "Dropout probability for LoRA layers to reduce overfitting.",
+ visionLayers: "Vision layers",
+ languageLayers: "Language layers",
+ attentionModules: "Attention modules",
+ mlpModules: "MLP modules",
+ targetModules: "Target Modules",
+ enableLora: "Enable LoRA",
+ trainWithLora: "Train with LoRA",
+ stableRank: "Stable Rank",
+ memoryEfficient: "Memory Efficient",
+ optimization: "Optimization",
+ schedule: "Schedule",
+ memory: "Memory",
+ optimizer: "Optimizer",
+ optimizerTooltip:
+ "Optimization algorithm. 8-bit variants reduce memory usage. Fused is recommended for vision models.",
+ lrScheduler: "LR scheduler",
+ lrSchedulerTooltip:
+ "How the learning rate changes over training. Linear decays steadily; cosine decays in a curve.",
+ optimizerOptions: {
+ adamw8bit: "AdamW 8-bit",
+ pagedAdamw8bit: "Paged AdamW 8-bit",
+ adamwBnb8bit: "AdamW BNB 8-bit",
+ pagedAdamw32bit: "Paged AdamW 32-bit",
+ adamwTorch: "AdamW (PyTorch)",
+ adamwTorchFused: "AdamW (PyTorch Fused)",
+ },
+ lrSchedulerOptions: {
+ linear: "Linear",
+ cosine: "Cosine",
+ },
+ batchSize: "Batch Size",
+ batchSizeTooltip: "Samples processed per step. Higher uses more VRAM.",
+ gradAccum: "Grad Accum",
+ gradAccumTooltip: "Simulates larger batch sizes without extra VRAM.",
+ weightDecay: "Weight Decay",
+ weightDecayTooltip: "L2 regularization to prevent overfitting.",
+ warmupSteps: "Warmup Steps",
+ warmupStepsTooltip: "Gradually increase LR at training start for stability.",
+ scheduleEpochsTooltip:
+ "Number of full passes over the dataset. Set 0 to run by max steps.",
+ saveSteps: "Save Steps",
+ saveStepsTooltip: "Save a checkpoint every N steps. 0 to disable.",
+ evalSteps: "Eval Steps",
+ evalStepsTooltip:
+ "Fraction of total training steps between evaluations (0-1). Set to 0 to disable evaluation. E.g. 0.01 = evaluate every 1% of steps.",
+ seed: "Seed",
+ seedTooltip: "Random seed for reproducibility.",
+ gradCheckpoint: "Grad Checkpoint",
+ gradCheckpointTooltip:
+ "Trade compute for memory by recomputing activations.",
+ none: "None",
+ standard: "Standard",
+ enablePacking: "Enable packing",
+ assistantCompletionsOnly: "Assistant completions only",
+ readMore: "Read more",
+ },
+ training: {
+ title: "Training",
+ description: "Monitor and control training",
+ chartNoDataTitle: "No training data yet",
+ chartNoDataDescription: "Start training to see loss progress",
+ startTraining: "Start Training",
+ starting: "Starting...",
+ loadingModel: "Loading model...",
+ checkingDataset: "Checking dataset...",
+ configLabel: "Training Config",
+ upload: "Upload",
+ uploadConfigTooltip: "Load a saved YAML config",
+ save: "Save",
+ saveConfigTooltip: "Download current config as YAML",
+ reset: "Reset",
+ resetConfigTooltip: "Reset to model defaults",
+ configLoaded: "Config loaded",
+ failedToLoadConfig: "Failed to load config",
+ invalidYamlFile: "Invalid YAML file",
+ failedToReadFile: "Failed to read file",
+ parametersReset: "Parameters reset to model defaults",
+ audioIncompatible:
+ "This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset.",
+ visionIncompatible:
+ "Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset.",
+ cancelTitle: "Cancel Training",
+ cancelDescription: "Do you want to cancel the current training run?",
+ continueAction: "Continue Training",
+ cancelAction: "Cancel Training",
+ stopTitle: "Stop Training",
+ stopDescription: "Choose how you want to stop the current training run.",
+ stopAction: "Stop",
+ stopping: "Stopping...",
+ stopAndSave: "Stop and Save",
+ compareInChat: "Compare in Chat",
+ exportModel: "Export Model",
+ milestone: "Milestone",
+ halfwayDone: "Halfway done. Training is past 50%.",
+ doneNextStep: "Training done. Next step: compare base vs fine-tuned outputs.",
+ },
+ history: {
+ title: "History",
+ emptyTitle: "No training runs yet",
+ emptyDescription:
+ "No training runs yet. Start your first training run in the Configure tab.",
+ loadError: "Failed to load training runs",
+ deleteError: "Failed to delete training run. Please try again.",
+ retry: "Retry",
+ loadMore: "Load more",
+ loading: "Loading...",
+ loadingRun: "Loading training run...",
+ runNotFound: "Run not found",
+ deleteTitle: "Delete training run?",
+ deleteDescription:
+ "This will permanently delete this training run and all its metrics. This action cannot be undone.",
+ runCount: "{count} runs",
+ oneRun: "1 run",
+ resume: "Resume",
+ resumeTraining: "Resume training",
+ resuming: "Resuming...",
+ deleteRun: "Delete run",
+ loss: "Loss",
+ steps: "Steps",
+ lossTrendSparkline: "Loss trend sparkline",
+ relativeJustNow: "just now",
+ relativeMinutesAgo: "{count}m ago",
+ relativeHoursAgo: "{count}h ago",
+ relativeDaysAgo: "{count}d ago",
+ status: {
+ completed: "Completed",
+ stopped: "Stopped",
+ error: "Error",
+ running: "Running",
+ continued: "Continued",
+ },
+ message: {
+ completed: "Training completed",
+ stopped: "Training stopped",
+ running: "Training in progress",
+ errored: "Training errored",
+ },
+ },
+ charts: {
+ settings: "Chart Settings",
+ settingsDescription: "Tune chart presentation while training keeps running.",
+ openSettings: "Open chart settings",
+ viewWindow: "View window",
+ viewWindowDescription: "Show latest steps only or the full history.",
+ window: "Window",
+ all: "All",
+ trainingLoss: "Training Loss",
+ trainingLossDescription: "Control overlays and EMA smoothing.",
+ smoothing: "Smoothing",
+ smoothingDescription: "Move right for more smoothing. `0` = raw.",
+ showRawLoss: "Show raw loss",
+ showSmoothedLoss: "Show smoothed loss",
+ showAverageLine: "Show average line",
+ scaleAndCleanup: "Scale and cleanup",
+ linear: "Linear",
+ log: "Log",
+ noClip: "No clip",
+ clipP99: "Clip p99",
+ clipP95: "Clip p95",
+ lossAxis: "Loss axis",
+ gradientNormAxis: "Gradient norm axis",
+ learningRateAxis: "Learning rate axis",
+ resetDefaults: "Reset defaults",
+ loss: "Loss",
+ smoothed: "Smoothed",
+ evalLoss: "Eval Loss",
+ learningRate: "Learning Rate",
+ lr: "LR",
+ gradNorm: "Grad Norm",
+ gradientNorm: "Gradient Norm",
+ step: "Step {step}",
+ averageValue: "avg {value}",
+ waitingForFirstEvaluationStep: "Waiting for first evaluation step...",
+ evaluationNotConfigured: "Evaluation not configured",
+ evalChartWillAppear: "Chart will appear once eval_steps is reached",
+ setEvalDatasetAndSteps: "Set eval dataset & eval_steps to track eval loss",
+ },
+ progress: {
+ title: "Training Progress",
+ liveMetrics: "Live training metrics",
+ openConfig: "Open training config",
+ configLabel: "Training Config",
+ hyperparams: "Hyperparams",
+ epochs: "Epochs",
+ batchSize: "Batch size",
+ learningRate: "Learning rate",
+ optimizer: "Optimizer",
+ maxSteps: "Max steps",
+ contextLength: "Context length",
+ warmupSteps: "Warmup steps",
+ rank: "Rank",
+ alpha: "Alpha",
+ dropout: "Dropout",
+ variant: "Variant",
+ epoch: "Epoch {value}",
+ percentComplete: "{percent}% complete",
+ stepProgress: "Step {current} / {total}",
+ loss: "Loss",
+ lr: "LR",
+ gradNorm: "Grad Norm",
+ model: "Model",
+ method: "Method",
+ elapsed: "Elapsed: {value}",
+ eta: "ETA: {value}",
+ stepsPerSecond: "{value} steps/s",
+ noStepsPerSecond: "-- steps/s",
+ tokens: "Tokens: {value}",
+ gpuMonitor: "GPU Monitor",
+ live: "Live",
+ utilization: "Utilization",
+ temperature: "Temperature",
+ vram: "VRAM",
+ power: "Power",
+ phase: {
+ idle: "Idle",
+ downloadingModel: "Downloading model",
+ downloadingDataset: "Downloading dataset",
+ loadingModel: "Loading model",
+ loadingDataset: "Loading dataset",
+ configuring: "Configuring",
+ training: "Training",
+ completed: "Completed",
+ error: "Error",
+ stopped: "Stopped",
+ },
+ },
+ trainingStart: {
+ ready: "Ready",
+ downloading: "Downloading",
+ preparing: "Preparing",
+ left: "{eta} left",
+ downloaded: "{size} downloaded",
+ terminalStart: "> unsloth training starts...",
+ preparingResources: "> Preparing model and dataset...",
+ gettingReady: "> We are getting everything ready for your run...",
+ waitingForFirstStep: "> {message} | waiting for first step... ({step})",
+ resumingTraining: "Resuming training...",
+ startingTraining: "starting training...",
+ dataset: "Dataset",
+ modelWeights: "Model weights",
+ },
+ tour: {
+ guidedTour: "Guided Tour",
+ },
+ },
+} as const;
diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts
new file mode 100644
index 0000000000..4074a5760b
--- /dev/null
+++ b/studio/frontend/src/i18n/locales/zh-CN.ts
@@ -0,0 +1,712 @@
+// 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 type { DeepPartialMessageTree } from "../types";
+import type { en } from "./en";
+
+export const zhCN = {
+ common: {
+ cancel: "取消",
+ close: "关闭",
+ delete: "删除",
+ done: "完成",
+ error: "错误",
+ export: "导出",
+ help: "帮助",
+ loading: "加载中...",
+ new: "新增",
+ rename: "重命名",
+ save: "保存",
+ search: "搜索",
+ shutdown: "关闭服务",
+ },
+ shell: {
+ accountMenu: "{name} 账号菜单",
+ aria: {
+ home: "Unsloth 首页",
+ closeSidebar: "关闭侧边栏",
+ openSidebar: "打开侧边栏",
+ chatOptions: "聊天选项",
+ runOptions: "训练选项",
+ },
+ navigation: {
+ newChat: "新聊天",
+ compare: "对比",
+ search: "搜索",
+ train: "训练",
+ recipes: "配方",
+ export: "导出",
+ recents: "最近",
+ settings: "设置",
+ api: "API",
+ lightMode: "浅色模式",
+ darkMode: "深色模式",
+ guidedTour: "引导教程",
+ help: "帮助",
+ logOut: "退出登录",
+ shutdown: "关闭服务",
+ },
+ notFound: {
+ title: "页面未找到",
+ description: "{path} 不存在。",
+ backToChat: "返回聊天",
+ },
+ dialog: {
+ deleteChat: {
+ title: "删除聊天",
+ description: "确定要删除聊天“{name}”吗?",
+ },
+ deleteRun: {
+ title: "删除训练运行",
+ description: "确定要删除运行“{name}”吗?",
+ },
+ renameChat: {
+ title: "重命名聊天",
+ placeholder: "聊天标题",
+ },
+ renameRun: {
+ title: "重命名运行",
+ placeholder: "运行名称",
+ },
+ },
+ toast: {
+ cannotDeleteRunningRun: "不能删除正在运行的训练",
+ failedToDeleteChat: "删除聊天失败",
+ failedToDeleteRun: "删除运行失败",
+ failedToRenameChat: "重命名聊天失败",
+ failedToRenameRun: "重命名运行失败",
+ },
+ },
+ settings: {
+ title: "设置",
+ dialog: {
+ title: "设置",
+ description: "管理你的 Unsloth Studio 偏好设置。",
+ closeAriaLabel: "关闭设置",
+ },
+ tabs: {
+ general: "通用",
+ profile: "个人资料",
+ appearance: "外观",
+ chat: "聊天",
+ connections: "连接",
+ apiKeys: "API",
+ about: "帮助",
+ },
+ general: {
+ title: "通用",
+ description: "Unsloth Studio 的全局偏好设置。",
+ account: "账号",
+ huggingFaceToken: "Hugging Face token",
+ huggingFaceTokenDescription: "用于加载受限模型和推送产物。",
+ hideToken: "隐藏 token",
+ showToken: "显示 token",
+ chatDefaults: "聊天默认设置",
+ autoTitleNewChats: "自动为新聊天命名",
+ autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
+ gettingStarted: "入门",
+ startOnboarding: "开始引导",
+ startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
+ startOnboardingAction: "开始引导",
+ resetPreferences: {
+ sectionTitle: "危险区域",
+ label: "重置所有本地偏好设置",
+ description:
+ "清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
+ action: "重置偏好设置",
+ confirmTitle: "重置所有本地偏好设置?",
+ confirmDescription:
+ "这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
+ confirmAction: "重置并重新加载",
+ },
+ },
+ profile: {
+ title: "个人资料",
+ description: "更新你在 Studio 中显示的个人资料。",
+ changePicture: "更换头像",
+ displayName: "显示名称",
+ nameSaved: "个人资料名称已保存",
+ namePersistErrorTitle: "无法持久保存个人资料名称",
+ namePersistErrorDescription:
+ "名称已在本次会话中更新,但重新加载后可能不会保留。",
+ photoUpdated: "头像已更新",
+ photoPersistErrorTitle: "无法持久保存头像",
+ photoPersistErrorDescription:
+ "头像已在本次会话中更新,但重新加载后可能不会保留。",
+ photoUpdateErrorTitle: "无法更新头像",
+ imageUseError: "无法使用这张图片。",
+ },
+ appearance: {
+ title: "外观",
+ description: "调整 Unsloth Studio 在此设备上的显示方式。",
+ language: {
+ title: "语言",
+ label: "显示语言",
+ description: "选择 Studio 使用的语言。",
+ },
+ theme: {
+ title: "主题",
+ label: "颜色主题",
+ description: "选择浅色、深色,或跟随系统。",
+ system: "跟随系统",
+ light: "浅色",
+ dark: "深色",
+ },
+ layout: {
+ title: "布局",
+ compactSidebar: "默认固定侧边栏",
+ compactSidebarDescription: "保持侧边栏展开,而不是折叠为图标。",
+ },
+ },
+ chat: {
+ title: "聊天",
+ description: "管理此设备上保存的聊天记录。",
+ data: "数据",
+ exportHistory: "导出聊天记录",
+ exportHistoryDescription: "将所有聊天和消息下载为 JSON 文件。",
+ exportAction: "导出",
+ exportingAction: "导出中...",
+ clearHistory: "清除聊天记录",
+ clearHistoryDescription: "从此设备删除本地聊天记录。",
+ clearAction: "清除",
+ clearAllChats: "清除所有聊天",
+ clearAllChatsDescription: "永久删除此设备上的每个聊天。",
+ noChatsToClear: "没有可清除的聊天。",
+ clearOneChatDescription: "永久删除此设备上的唯一一个聊天。",
+ clearChatCountDescription: "永久删除此设备上的 {count} 个聊天。",
+ clearChatsAction: "清除聊天",
+ clearOneChatTitle: "清除 1 个聊天?",
+ clearChatsTitle: "清除 {count} 个聊天?",
+ clearChatsConfirmDescription:
+ "这会永久删除此设备上保存的每个聊天和消息。此操作无法撤销。",
+ clearingAction: "清除中...",
+ clearOneChatAction: "清除 1 个聊天",
+ clearChatCountAction: "清除 {count} 个聊天",
+ clearedAllChats: "已清除所有聊天",
+ clearedOneChat: "已清除 1 个聊天",
+ clearedChatCount: "已清除 {count} 个聊天",
+ someChatsCouldNotBeCleared: "部分聊天无法清除",
+ chatsClearedRemainOne:
+ "已清除 {clearedCount} 个聊天;仍有 1 个聊天保留。请重试。",
+ chatsClearedRemain:
+ "已清除 {clearedCount} 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
+ oneChatClearedRemain:
+ "已清除 1 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
+ oneChatClearedRemainOne: "已清除 1 个聊天;仍有 1 个聊天保留。请重试。",
+ storageClearFailedOne:
+ "某个存储位置清除失败;可能仍有 1 个聊天保留。请重试。",
+ storageClearFailed:
+ "某个存储位置清除失败;可能仍有 {count} 个聊天保留。请重试。",
+ failedToClearChats: "清除聊天失败",
+ },
+ connections: {
+ title: "连接",
+ description: "管理提供方和外部服务的连接。",
+ },
+ apiKeys: {
+ title: "API",
+ description: "通过兼容 OpenAI 的 API 以编程方式访问 Unsloth。",
+ readDocs: "阅读 API 文档",
+ noAccess: "还没有 API 访问权限。",
+ newBadge: "新",
+ accessTokens: "访问 token",
+ loadError: "无法加载 API 访问权限。",
+ createError: "无法创建访问 token。",
+ revokeError: "无法撤销访问 token。",
+ never: "永不过期",
+ tokenNamePlaceholder: "Token 名称(例如 production)",
+ newAccessTokenName: "新的访问 token 名称",
+ createToken: "创建 token",
+ creating: "创建中...",
+ newTokenCreated: "新的访问 token 已创建",
+ accessTokenCopied: "访问 token 已复制",
+ copyAccessToken: "复制访问 token",
+ copyNow: "现在复制 - 之后不会再次显示。",
+ usageExamples: "使用示例",
+ usageTools: "工具",
+ copySnippet: "复制代码片段",
+ copy: "复制",
+ copied: "已复制",
+ setupDocs: "设置文档:",
+ relativeNever: "从未",
+ relativeJustNow: "刚刚",
+ relativeHoursAgo: "{count} 小时前",
+ relativeDaysAgo: "{count} 天前",
+ relativeMonthsAgo: "{count} 个月前",
+ relativeYearsAgo: "{count} 年前",
+ expired: "已过期",
+ today: "今天",
+ inDays: "{count} 天后",
+ created: "创建于 {value}",
+ used: "使用于 {value}",
+ expires: "过期时间 {value}",
+ actionsFor: "{name} 的操作",
+ copyPrefix: "复制前缀",
+ revokeToken: "撤销 token",
+ revokeTitle: "撤销访问 token \"{name}\"?",
+ revokeDescription:
+ "使用此 token 的应用会立即失去访问权限。此操作无法撤销。",
+ revokeAction: "撤销 \"{name}\"",
+ revoking: "撤销中...",
+ },
+ about: {
+ title: "帮助",
+ description: "文档、发布说明、反馈和 Studio 构建信息。",
+ studioVersion: "Studio 版本",
+ packageVersion: "包版本",
+ updates: "更新",
+ help: "帮助",
+ documentation: "文档",
+ releaseNotes: "发布说明",
+ whatsNew: "最新内容",
+ feedback: "反馈",
+ reportIssue: "报告问题",
+ dangerZone: "危险区域",
+ shutDownStudio: "关闭 Unsloth Studio",
+ shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
+ shutDown: "关闭",
+ update: {
+ title: "更新 Unsloth Studio",
+ openPowerShell: "打开 PowerShell 并运行:",
+ openTerminal: "打开终端并运行:",
+ commandText: "{label} 文本",
+ copied: "已复制",
+ copyCommand: "复制命令",
+ commandCopied: "{label} 已复制",
+ copyNamedCommand: "复制 {label}",
+ checkingInstall: "正在检查 Studio 的安装方式...",
+ localInstallDetected:
+ "检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
+ pullThenUpdate:
+ "从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio:",
+ gitPullCommand: "git pull 命令",
+ localUpdateCommand: "本地更新命令",
+ localInstallerFallback:
+ "如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
+ localInstallerCommand: "本地安装器命令",
+ sourceInstallDetected:
+ "这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
+ repoCheckoutFallback:
+ "如果你仍保留 Unsloth 仓库 checkout,请从该 checkout 运行本地安装器:",
+ restartAfterUpdate: "更新后重启 Studio,使变更生效。",
+ unknownInstall:
+ "Studio 无法检测安装方式。请先确认你如何安装 Studio,然后选择匹配的更新方式。",
+ curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
+ updateCommand: "更新命令",
+ localCheckout:
+ "对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
+ fallbackInstruction:
+ "如果失败,或 unsloth studio update 不可用,请运行:",
+ fallbackCommand: "备用命令",
+ },
+ },
+ },
+ studio: {
+ routeTitle: "训练",
+ title: "微调工作台",
+ subtitles: {
+ configure: "配置并开始训练",
+ trainingInProgress: "训练进行中",
+ viewPastRuns: "查看历史训练",
+ viewingPastRun: "正在查看历史训练",
+ },
+ tabs: {
+ configure: "配置",
+ currentRun: "当前训练",
+ history: "历史",
+ },
+ loadingRuntime: "正在加载训练运行时...",
+ backToHistory: "返回历史",
+ sections: {
+ model: "模型",
+ dataset: "数据集",
+ params: "参数",
+ training: "训练",
+ charts: "图表",
+ progress: "训练进度",
+ },
+ configure: {
+ title: "配置",
+ description: "选择模型、数据集和训练设置。",
+ startTraining: "开始训练",
+ starting: "启动中...",
+ loadingModel: "正在加载模型...",
+ checkingDataset: "正在检查数据集...",
+ trainingConfig: "训练配置",
+ },
+ model: {
+ title: "模型",
+ description: "选择基础模型和训练方法",
+ fasterTrainingBadge: "训练速度提升 2 倍",
+ baseModel: "基础模型",
+ localModel: "本地模型",
+ localModelTooltip: "本地已下载模型的路径,或自定义 HF 仓库。",
+ scanningLocalAndCachedModels: "正在扫描本地和缓存模型...",
+ scanning: "正在扫描...",
+ scanningLocalModels: "正在扫描本地模型...",
+ noLocalModelsFound: "未找到本地模型",
+ noLocalModelsFoundManual: "未找到本地模型。请手动输入路径。",
+ failedToLoadLocalModels: "加载本地模型失败",
+ hfCache: "HF 缓存",
+ customFolders: "自定义文件夹",
+ localDir: "本地目录",
+ huggingFaceModel: "Hugging Face 模型",
+ huggingFaceModelTooltip: "搜索 Hugging Face 模型,或从推荐列表中选择。",
+ searchModels: "搜索模型...",
+ searching: "搜索中...",
+ noModelsFound: "未找到模型",
+ needsVram: "约需 {vram}GB 显存(GPU:{gpu}GB)",
+ tightVram: "约 {vram}GB 显存(在 {gpu}GB 上偏紧)",
+ vramEstimate: "约 {vram}GB 显存",
+ method: "方法",
+ methodTooltip:
+ "QLoRA 使用 4 位量化以最大限度降低显存。LoRA 使用 16 位。Full 会更新所有权重。CPT(持续预训练)在原始文本上训练,使模型适配新领域,不使用聊天格式。",
+ readMore: "了解更多",
+ fullFineTune: "全量微调",
+ checkingToken: "正在检查 token...",
+ getOrUpdateToken: "获取或更新 token",
+ huggingFaceTokenOptional: "Hugging Face Token(可选)",
+ continuedPretraining: "持续预训练",
+ localModels: "本地模型",
+ localModelsFound: "找到 {count} 个本地/缓存模型",
+ loadingLocalModels: "正在加载本地模型...",
+ },
+ dataset: {
+ title: "数据集",
+ description: "选择或上传训练数据",
+ source: "数据集来源",
+ chooseDataset: "选择数据集",
+ chooseDatasetTooltip:
+ "通过弹出标签切换 Hugging Face 与本地数据配方输出。",
+ localTab: "本地",
+ searchHuggingFaceDatasets: "搜索 Hugging Face 数据集...",
+ searchLocalDatasets: "搜索本地数据集...",
+ searching: "搜索中...",
+ noDatasetsFound: "未找到数据集",
+ loadingLocalDatasets: "正在加载本地数据集...",
+ failedToLoadLocalDatasets: "加载本地数据集失败。",
+ noLocalDatasetsYet: "还没有本地数据集。",
+ noLocalDatasetsMatchSearch: "没有本地数据集匹配搜索。",
+ openDataRecipes: "打开数据配方",
+ browsingSource: "正在浏览 {browsing}。当前选择仍保持为 {current}。",
+ localDatasets: "本地数据集",
+ localDataset: "本地数据集",
+ localDatasetRows: " / {count} 行",
+ huggingFaceDataset: "Hugging Face 数据集",
+ localDatasetMetadata: "本地数据集元数据",
+ dataRecipeOutput: "数据配方输出。",
+ rows: "行",
+ columns: "列",
+ batches: "批次",
+ updated: "更新时间",
+ evalDataset: "评估数据集",
+ uploading: "上传中...",
+ upload: "上传",
+ uploadEvalFile: "上传评估文件",
+ evalDatasetDescription:
+ "可选。如果未提供,将从训练数据中切分出一小部分。",
+ advanced: "高级",
+ targetFormat: "目标格式",
+ targetFormatTooltip:
+ "训练数据的格式。自动检测对大多数数据集都有效。",
+ auto: "自动",
+ rawText: "原始文本",
+ trainSplitStart: "训练切分起始",
+ trainSplitStartTooltip:
+ "通过指定起始行索引(含,从 0 开始)仅在训练切分的子集上训练。留空则从第一行开始。",
+ trainSplitEnd: "训练切分结束",
+ trainSplitEndTooltip:
+ "训练切分中包含的最后一行索引(含,从 0 开始)。例如将起始设为 0、结束设为 99,可在前 100 行上训练。留空则使用所有剩余行。",
+ endPlaceholder: "结束",
+ clear: "清除",
+ dropFileOrClick: "拖放 1 个文件到此处,或点击上传",
+ viewDataset: "查看数据集",
+ uploadFailed: "上传失败",
+ unknownError: "未知错误",
+ unsupportedFileType: "不支持的文件类型",
+ uploadOneFileType: "上传一个 {types} 文件。",
+ datasetUploaded: "数据集已上传",
+ evalDatasetUploaded: "评估数据集已上传",
+ uploadOneFileAtATime: "一次只能上传一个文件",
+ uploadSingleFileDescription: "训练数据集上传只接受单个文件。",
+ checkingToken: "正在检查 token...",
+ getOrUpdateToken: "获取或更新 token",
+ preview: "预览数据集",
+ split: "切分",
+ subset: "子集",
+ },
+ params: {
+ title: "参数",
+ description: "配置训练超参数",
+ loraSettings: "LoRA 设置",
+ trainingHyperparameters: "训练超参数",
+ maxSteps: "最大步数",
+ epochs: "轮数",
+ useMaxSteps: "使用最大步数",
+ useEpochs: "使用轮数",
+ maxStepsTooltip: "覆盖优化器总步数。",
+ epochsTooltip: "完整遍历数据集的次数。",
+ epochsDescription: "每个 epoch 是对数据集的一次完整遍历。",
+ maxStepsDescription: "将训练限制为固定数量的优化器步数。",
+ contextLength: "上下文长度",
+ contextLengthTooltip: "每个训练样本的最大 token 数。",
+ customContextLength: "输入自定义值",
+ contextLengthDescription: "训练样本的最大序列长度",
+ learningRate: "学习率",
+ learningRateTooltip: "权重更新步长。较低的值训练更慢但更稳定。",
+ learningRateDescription:
+ "推荐值:LoRA 用 2e-4,CPT 用 5e-5,全量微调用 2e-5",
+ embeddingLearningRate: "Embedding 学习率",
+ embeddingLearningRateTooltip:
+ "仅在 CPT 训练 embed_tokens 时使用。Embedding 比 LoRA 权重更易失稳,通常需要更小的学习率。留空则使用 lr/10;常用区间是比主学习率小 2 至 10 倍。仅在词表或领域 token 适配过慢时才提高。",
+ embeddingLearningRateDescription:
+ "留空使用 lr/10(推荐)。常用区间是比主学习率小 2 至 10 倍。",
+ rank: "Rank",
+ rankTooltip: "低秩矩阵的维度。越高容量越大。",
+ alpha: "Alpha",
+ alphaTooltip: "LoRA 更新的缩放因子。通常为 Rank 的 2 倍。",
+ dropout: "Dropout",
+ dropoutTooltip: "LoRA 层的 dropout 概率,用于减少过拟合。",
+ visionLayers: "视觉层",
+ languageLayers: "语言层",
+ attentionModules: "注意力模块",
+ mlpModules: "MLP 模块",
+ targetModules: "目标模块",
+ enableLora: "启用 LoRA",
+ trainWithLora: "使用 LoRA 训练",
+ stableRank: "稳定 Rank",
+ memoryEfficient: "节省内存",
+ optimization: "优化",
+ schedule: "计划",
+ memory: "内存",
+ optimizer: "优化器",
+ optimizerTooltip:
+ "优化算法。8 位变体可降低内存占用。对视觉模型推荐 Fused。",
+ lrScheduler: "LR 调度器",
+ lrSchedulerTooltip:
+ "学习率随训练变化的方式。Linear 平稳衰减;Cosine 曲线衰减。",
+ optimizerOptions: {
+ adamw8bit: "AdamW 8-bit",
+ pagedAdamw8bit: "Paged AdamW 8-bit",
+ adamwBnb8bit: "AdamW BNB 8-bit",
+ pagedAdamw32bit: "Paged AdamW 32-bit",
+ adamwTorch: "AdamW(PyTorch)",
+ adamwTorchFused: "AdamW(PyTorch Fused)",
+ },
+ lrSchedulerOptions: {
+ linear: "线性",
+ cosine: "余弦",
+ },
+ batchSize: "批大小",
+ batchSizeTooltip: "每步处理的样本数。越高占用越多显存。",
+ gradAccum: "梯度累积",
+ gradAccumTooltip: "在不增加显存的情况下模拟更大的批大小。",
+ weightDecay: "权重衰减",
+ weightDecayTooltip: "L2 正则化,用于防止过拟合。",
+ warmupSteps: "预热步数",
+ warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。",
+ scheduleEpochsTooltip:
+ "完整遍历数据集的次数。设为 0 则按最大步数运行。",
+ saveSteps: "保存步数",
+ saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。",
+ evalSteps: "评估步数",
+ evalStepsTooltip:
+ "评估之间间隔占总训练步数的比例(0-1)。设为 0 则禁用评估。例如 0.01 = 每 1% 步评估一次。",
+ seed: "随机种子",
+ seedTooltip: "用于复现的随机种子。",
+ gradCheckpoint: "梯度检查点",
+ gradCheckpointTooltip: "通过重算激活以时间换显存。",
+ none: "无",
+ standard: "标准",
+ enablePacking: "启用 packing",
+ assistantCompletionsOnly: "仅助手回复",
+ readMore: "了解更多",
+ },
+ training: {
+ title: "训练",
+ description: "监控和控制训练",
+ chartNoDataTitle: "暂无训练数据",
+ chartNoDataDescription: "开始训练后可查看 loss 进度",
+ startTraining: "开始训练",
+ starting: "启动中...",
+ loadingModel: "正在加载模型...",
+ checkingDataset: "正在检查数据集...",
+ configLabel: "训练配置",
+ upload: "上传",
+ uploadConfigTooltip: "加载已保存的 YAML 配置",
+ save: "保存",
+ saveConfigTooltip: "将当前配置下载为 YAML",
+ reset: "重置",
+ resetConfigTooltip: "重置为模型默认值",
+ configLoaded: "配置已加载",
+ failedToLoadConfig: "加载配置失败",
+ invalidYamlFile: "无效的 YAML 文件",
+ failedToReadFile: "读取文件失败",
+ parametersReset: "参数已重置为模型默认值",
+ audioIncompatible:
+ "该模型不支持音频。请切换到支持音频的模型,或选择非音频数据集。",
+ visionIncompatible:
+ "文本模型与多模态数据集不兼容。请切换到视觉模型,或选择纯文本数据集。",
+ cancelTitle: "取消训练",
+ cancelDescription: "要取消当前训练运行吗?",
+ continueAction: "继续训练",
+ cancelAction: "取消训练",
+ stopTitle: "停止训练",
+ stopDescription: "选择如何停止当前训练运行。",
+ stopAction: "停止",
+ stopping: "停止中...",
+ stopAndSave: "停止并保存",
+ compareInChat: "在聊天中对比",
+ exportModel: "导出模型",
+ milestone: "里程碑",
+ halfwayDone: "已完成一半。训练进度超过 50%。",
+ doneNextStep: "训练完成。下一步:对比基础模型和微调模型的输出。",
+ },
+ history: {
+ title: "历史",
+ emptyTitle: "还没有训练运行",
+ emptyDescription: "还没有训练运行。请在配置标签页开始第一次训练。",
+ loadError: "加载训练运行失败",
+ deleteError: "删除训练运行失败。请重试。",
+ retry: "重试",
+ loadMore: "加载更多",
+ loading: "加载中...",
+ loadingRun: "正在加载训练运行...",
+ runNotFound: "未找到运行",
+ deleteTitle: "删除训练运行?",
+ deleteDescription: "这会永久删除该训练运行及其所有指标。此操作无法撤销。",
+ runCount: "{count} 次运行",
+ oneRun: "1 次运行",
+ resume: "继续",
+ resumeTraining: "继续训练",
+ resuming: "继续中...",
+ deleteRun: "删除运行",
+ loss: "Loss",
+ steps: "步数",
+ lossTrendSparkline: "Loss 趋势迷你图",
+ relativeJustNow: "刚刚",
+ relativeMinutesAgo: "{count} 分钟前",
+ relativeHoursAgo: "{count} 小时前",
+ relativeDaysAgo: "{count} 天前",
+ status: {
+ completed: "已完成",
+ stopped: "已停止",
+ error: "错误",
+ running: "运行中",
+ continued: "已继续",
+ },
+ message: {
+ completed: "训练已完成",
+ stopped: "训练已停止",
+ running: "训练进行中",
+ errored: "训练出错",
+ },
+ },
+ charts: {
+ settings: "图表设置",
+ settingsDescription: "训练运行时调整图表显示。",
+ openSettings: "打开图表设置",
+ viewWindow: "查看窗口",
+ viewWindowDescription: "只显示最新步数或完整历史。",
+ window: "窗口",
+ all: "全部",
+ trainingLoss: "训练损失",
+ trainingLossDescription: "控制覆盖线和 EMA 平滑。",
+ smoothing: "平滑",
+ smoothingDescription: "向右移动可增加平滑度。`0` = 原始值。",
+ showRawLoss: "显示原始 loss",
+ showSmoothedLoss: "显示平滑 loss",
+ showAverageLine: "显示平均线",
+ scaleAndCleanup: "比例和清理",
+ linear: "线性",
+ log: "对数",
+ noClip: "不裁剪",
+ clipP99: "裁剪 p99",
+ clipP95: "裁剪 p95",
+ lossAxis: "损失轴",
+ gradientNormAxis: "梯度范数轴",
+ learningRateAxis: "学习率轴",
+ resetDefaults: "恢复默认值",
+ loss: "Loss",
+ smoothed: "平滑",
+ evalLoss: "评估 Loss",
+ learningRate: "学习率",
+ lr: "LR",
+ gradNorm: "梯度范数",
+ gradientNorm: "梯度范数",
+ step: "步数 {step}",
+ averageValue: "平均 {value}",
+ waitingForFirstEvaluationStep: "等待首次评估步...",
+ evaluationNotConfigured: "未配置评估",
+ evalChartWillAppear: "达到 eval_steps 后会显示图表",
+ setEvalDatasetAndSteps: "设置评估数据集和 eval_steps 以追踪评估 loss",
+ },
+ progress: {
+ title: "训练进度",
+ liveMetrics: "实时训练指标",
+ openConfig: "打开训练配置",
+ configLabel: "训练配置",
+ hyperparams: "超参数",
+ epochs: "轮数",
+ batchSize: "批大小",
+ learningRate: "学习率",
+ optimizer: "优化器",
+ maxSteps: "最大步数",
+ contextLength: "上下文长度",
+ warmupSteps: "预热步数",
+ rank: "Rank",
+ alpha: "Alpha",
+ dropout: "Dropout",
+ variant: "变体",
+ epoch: "Epoch {value}",
+ percentComplete: "完成 {percent}%",
+ stepProgress: "步数 {current} / {total}",
+ loss: "Loss",
+ lr: "LR",
+ gradNorm: "梯度范数",
+ model: "模型",
+ method: "方法",
+ elapsed: "已用时间:{value}",
+ eta: "ETA:{value}",
+ stepsPerSecond: "{value} 步/秒",
+ noStepsPerSecond: "-- 步/秒",
+ tokens: "Tokens:{value}",
+ gpuMonitor: "GPU 监控",
+ live: "实时",
+ utilization: "利用率",
+ temperature: "温度",
+ vram: "VRAM",
+ power: "功耗",
+ phase: {
+ idle: "空闲",
+ downloadingModel: "正在下载模型",
+ downloadingDataset: "正在下载数据集",
+ loadingModel: "正在加载模型",
+ loadingDataset: "正在加载数据集",
+ configuring: "配置中",
+ training: "训练中",
+ completed: "已完成",
+ error: "错误",
+ stopped: "已停止",
+ },
+ },
+ trainingStart: {
+ ready: "就绪",
+ downloading: "下载中",
+ preparing: "准备中",
+ left: "剩余 {eta}",
+ downloaded: "已下载 {size}",
+ terminalStart: "> Unsloth 训练开始...",
+ preparingResources: "> 正在准备模型和数据集...",
+ gettingReady: "> 正在为本次运行做好准备...",
+ waitingForFirstStep: "> {message} | 等待第一步...({step})",
+ resumingTraining: "正在继续训练...",
+ startingTraining: "正在开始训练...",
+ dataset: "数据集",
+ modelWeights: "模型权重",
+ },
+ tour: {
+ guidedTour: "引导教程",
+ },
+ },
+} satisfies DeepPartialMessageTree;
diff --git a/studio/frontend/src/i18n/messages.ts b/studio/frontend/src/i18n/messages.ts
new file mode 100644
index 0000000000..e75faf697a
--- /dev/null
+++ b/studio/frontend/src/i18n/messages.ts
@@ -0,0 +1,76 @@
+// 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 { getLocale } from "./locale-store";
+import { en } from "./locales/en";
+import { zhCN } from "./locales/zh-CN";
+import type { InterpolationValues, MessageKey } from "./types";
+
+export const LOCALES = {
+ en: { label: "English", nativeLabel: "English" },
+ "zh-CN": { label: "Chinese (Simplified)", nativeLabel: "简体中文" },
+} as const;
+
+export type Locale = keyof typeof LOCALES;
+export type TranslationKey = MessageKey;
+
+export const messages = { en, "zh-CN": zhCN } as const;
+
+const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
+
+function readMessage(tree: unknown, key: string): string | undefined {
+ let cursor = tree;
+ for (const segment of key.split(".")) {
+ if (
+ cursor === null ||
+ typeof cursor !== "object" ||
+ !Object.prototype.hasOwnProperty.call(cursor, segment)
+ ) {
+ return undefined;
+ }
+ cursor = (cursor as Record)[segment];
+ }
+ return typeof cursor === "string" ? cursor : undefined;
+}
+
+function interpolate(
+ template: string,
+ values: InterpolationValues | undefined,
+): string {
+ if (!values) return template;
+
+ return template.replace(PLACEHOLDER_PATTERN, (match, name: string) => {
+ if (!Object.prototype.hasOwnProperty.call(values, name)) return match;
+ const value = values[name];
+ return value === null || value === undefined ? "" : String(value);
+ });
+}
+
+function warnMissingEnglishMessage(key: string): void {
+ if (import.meta.env.DEV) {
+ console.warn(`[i18n] Missing English translation for key "${key}".`);
+ }
+}
+
+export function translate(
+ key: TranslationKey,
+ values?: InterpolationValues,
+ locale: Locale = getLocale(),
+): string {
+ const localized = readMessage(messages[locale], key);
+ const fallback = localized ?? readMessage(messages.en, key);
+
+ if (fallback === undefined) {
+ warnMissingEnglishMessage(key);
+ return key;
+ }
+
+ return interpolate(fallback, values);
+}
+
+export function isSupportedLocale(value: unknown): value is Locale {
+ return (
+ typeof value === "string" &&
+ Object.prototype.hasOwnProperty.call(LOCALES, value)
+ );
+}
diff --git a/studio/frontend/src/i18n/types.ts b/studio/frontend/src/i18n/types.ts
new file mode 100644
index 0000000000..706c3fb1d3
--- /dev/null
+++ b/studio/frontend/src/i18n/types.ts
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+export type MessageTree = {
+ readonly [key: string]: string | MessageTree;
+};
+
+export type DeepPartialMessageTree = {
+ readonly [K in keyof T]?: T[K] extends string
+ ? string
+ : T[K] extends MessageTree
+ ? DeepPartialMessageTree
+ : never;
+};
+
+type Join =
+ Prefix extends "" ? Key : `${Prefix}.${Key}`;
+
+export type MessageKey = {
+ [K in Extract]: T[K] extends string
+ ? Join
+ : T[K] extends MessageTree
+ ? MessageKey>
+ : never;
+}[Extract];
+
+export type InterpolationValues = Record<
+ string,
+ string | number | boolean | null | undefined
+>;
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css
index 57802c6d44..9812552b61 100644
--- a/studio/frontend/src/index.css
+++ b/studio/frontend/src/index.css
@@ -1238,6 +1238,53 @@
border-color: var(--border) !important;
}
+.generated-image-loading-card {
+ position: relative;
+ overflow: hidden;
+ contain: paint;
+}
+
+.generated-image-loading-wave {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ gap: 14px;
+ width: min(66%, 18rem);
+ padding: 1.5rem;
+ border-radius: 1.5rem;
+}
+
+.generated-image-loading-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 9999px;
+ background: color-mix(in oklch, var(--muted-foreground) 82%, var(--primary));
+ opacity: 0.12;
+ transform: translate3d(0, 4px, 0) scale(0.72);
+ animation: generated-image-dot-wave 1850ms var(--ease-out-quart) infinite;
+ animation-delay: calc((var(--dot-row) * 72ms) + (var(--dot-col) * 72ms));
+ will-change: transform, opacity;
+}
+
+@keyframes generated-image-dot-wave {
+ 0%,
+ 22%,
+ 100% {
+ opacity: 0.1;
+ transform: translate3d(0, 4px, 0) scale(0.72);
+ }
+
+ 46% {
+ opacity: 0.46;
+ transform: translate3d(0, -3px, 0) scale(0.96);
+ }
+
+ 66% {
+ opacity: 0.2;
+ transform: translate3d(0, 0, 0) scale(0.82);
+ }
+}
+
/*
* prefers-reduced-motion: honour the OS-level "reduce motion" preference.
* Tailwind animate-in/out, Radix open/close transforms, infinite shine/pulse
@@ -1247,11 +1294,11 @@
* end state. Hover colour changes become instant rather than fading, which is
* the documented WCAG outcome (motion is "minimised, not removed").
*
- * .animate-spin is the exception: loading spinners are essential progress
+ * .animate-spin and generated image loading dots are the exceptions: loading
* indicators across Studio (tool execution loaders, sonner toasts, Tauri
- * startup / update screens, the primitive). Freezing them
- * removes the only visual signal that work is in flight, so they keep
- * animating but at a slower, less aggressive 1.5s cadence.
+ * startup / update screens, the primitive, and image generation
+ * cards). Freezing them removes the only visual signal that work is in flight,
+ * so they keep animating.
*/
@media (prefers-reduced-motion: reduce) {
*,
@@ -1267,4 +1314,9 @@
animation-duration: 1.5s !important;
animation-iteration-count: infinite !important;
}
+
+ .generated-image-loading-dot {
+ animation-duration: 1850ms !important;
+ animation-iteration-count: infinite !important;
+ }
}
diff --git a/studio/frontend/src/main.tsx b/studio/frontend/src/main.tsx
index 0db37fdb6e..a924c1652e 100644
--- a/studio/frontend/src/main.tsx
+++ b/studio/frontend/src/main.tsx
@@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { fetchDeviceType } from "./config/env";
import { App } from "./app/app";
+import { initializeLocale } from "./i18n";
const globalCrypto = globalThis.crypto as Crypto | undefined;
@@ -33,6 +34,8 @@ if (!rootElement) {
throw new Error("Root element not found");
}
+initializeLocale();
+
fetchDeviceType().then(() => {
createRoot(rootElement).render(
diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py
index 91076a4743..b9d63ecee9 100644
--- a/studio/install_llama_prebuilt.py
+++ b/studio/install_llama_prebuilt.py
@@ -1349,6 +1349,24 @@ def direct_upstream_release_plan(
install_kind = "windows-cpu",
)
)
+ elif host.is_windows and host.is_arm64:
+ # Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-win-cpu-arm64.zip
+ # (visible in the b9334 release manifest). Without this branch the
+ # selector returned 0 attempts and the installer fell back to a
+ # source build on every Windows ARM64 host.
+ cpu_asset = f"llama-{release_tag}-bin-win-cpu-arm64.zip"
+ cpu_url = assets.get(cpu_asset)
+ if cpu_url:
+ attempts.append(
+ AssetChoice(
+ repo = repo,
+ tag = release_tag,
+ name = cpu_asset,
+ url = cpu_url,
+ source_label = "upstream",
+ install_kind = "windows-arm64",
+ )
+ )
elif host.is_macos and host.is_arm64:
asset_name = f"llama-{release_tag}-bin-macos-arm64.tar.gz"
asset_url = assets.get(asset_name)
@@ -1391,6 +1409,25 @@ def direct_upstream_release_plan(
install_kind = "linux-cpu",
)
)
+ elif host.is_linux and host.is_arm64 and not host.has_usable_nvidia:
+ # Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-ubuntu-arm64.tar.gz
+ # (visible in the b9334 release manifest). Without this branch the
+ # selector returned 0 attempts and the installer fell back to a
+ # source build on every Linux ARM64 host (DGX Spark, Ampere
+ # Altra, GitHub-hosted ubuntu-24.04-arm runners, etc.).
+ asset_name = f"llama-{release_tag}-bin-ubuntu-arm64.tar.gz"
+ asset_url = assets.get(asset_name)
+ if asset_url:
+ attempts.append(
+ AssetChoice(
+ repo = repo,
+ tag = release_tag,
+ name = asset_name,
+ url = asset_url,
+ source_label = "upstream",
+ install_kind = "linux-arm64",
+ )
+ )
if not attempts:
raise PrebuiltFallback("no compatible upstream prebuilt asset was found")
return InstallReleasePlan(
@@ -2603,12 +2640,18 @@ def detect_host() -> HostInfo:
try:
result = run_capture([nvidia_smi], timeout = 20)
merged = "\n".join(part for part in (result.stdout, result.stderr) if part)
- for line in merged.splitlines():
- if "CUDA Version:" in line:
- raw = line.split("CUDA Version:", 1)[1].strip().split()[0]
- major, minor = raw.split(".", 1)
- driver_cuda_version = (int(major), int(minor))
- break
+ # Newer NVIDIA drivers (e.g. 610.x on Windows) print
+ # "CUDA UMD Version: X.Y" instead of the legacy
+ # "CUDA Version: X.Y"; accept both spellings.
+ cuda_match = re.search(
+ r"CUDA(?: UMD)? Version:\s*(\d+)\.(\d+)",
+ merged,
+ )
+ if cuda_match is not None:
+ driver_cuda_version = (
+ int(cuda_match.group(1)),
+ int(cuda_match.group(2)),
+ )
except Exception:
pass
@@ -3827,37 +3870,23 @@ def paired_runtime_dll_patterns(choice: AssetChoice) -> list[str]:
def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
- if choice.install_kind in {"linux-cpu", "linux-cuda", "linux-rocm"}:
- return [
- "llama-server",
- "llama-quantize",
- "libllama-common.so*",
- "libllama.so*",
- # Upstream llama.cpp split the per-binary entry code into
- # paired ``libllama--impl.so`` shared libraries
- # around release b9261. ``llama-server`` and
- # ``llama-quantize`` are NEEDED-linked against
- # ``libllama-server-impl.so`` / ``libllama-quantize-impl.so``
- # respectively, with RUNPATH ``$ORIGIN``. Without copying
- # the impl ``.so`` files alongside the binaries, ldd
- # reports them missing, preflight rejects the install, and
- # the installer falls back to a source build on a fresh
- # Linux install. Glob the whole family so future bundles
- # that split additional binaries (e.g. ``llama-cli``,
- # ``llama-bench``) keep working.
- "libllama-*-impl.so*",
- "libggml.so*",
- "libggml-base.so*",
- "libmtmd.so*",
- "libggml-cpu-*.so*",
- "libggml-cuda.so*",
- "libggml-hip.so*",
- "libggml-rpc.so*",
- ]
+ # Broad shared-library glob + explicit binary names. Lets upstream
+ # repackage the SO/DLL set (e.g. ggml-org/llama.cpp#23462 split the
+ # per-binary entry code into paired ``lib-impl.so`` shared
+ # libraries between b9279 and b9283) without us re-enumerating
+ # every new file. Studio only invokes llama-server and llama-quantize;
+ # other CLIs upstream ships (llama-cli, llama-bench, ...) are skipped.
+ if choice.install_kind in {"linux-cpu", "linux-cuda", "linux-rocm", "linux-arm64"}:
+ return ["llama-server", "llama-quantize", "lib*.so*"]
if choice.install_kind in {"macos-arm64", "macos-x64"}:
return ["llama-server", "llama-quantize", "lib*.dylib"]
- if choice.install_kind in {"windows-cpu", "windows-cuda", "windows-hip"}:
- return ["*.exe", "*.dll"]
+ if choice.install_kind in {
+ "windows-cpu",
+ "windows-cuda",
+ "windows-hip",
+ "windows-arm64",
+ }:
+ return ["llama-server.exe", "llama-quantize.exe", "*.dll"]
raise PrebuiltFallback(
f"unsupported install kind for runtime overlay: {choice.install_kind}"
)
@@ -5207,7 +5236,7 @@ def load_prebuilt_metadata(install_dir: Path) -> dict[str, Any] | None:
def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
- if choice.install_kind == "linux-cpu":
+ if choice.install_kind in {"linux-cpu", "linux-arm64"}:
return [
["libllama-common.so*"],
["libllama.so*"],
@@ -5242,7 +5271,7 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
["libmtmd.so*"],
["libggml-hip.so*"],
]
- if choice.install_kind == "windows-cpu":
+ if choice.install_kind in {"windows-cpu", "windows-arm64"}:
return [["llama.dll"]]
if choice.install_kind == "windows-cuda":
groups = [["llama.dll"], ["ggml-cuda.dll"]]
@@ -5317,6 +5346,15 @@ def existing_install_matches_choice(
for binary in ("llama-server", "llama-quantize"):
if not (runtime_dir / f"{binary}{ext}").exists():
return False
+ if host.is_linux:
+ try:
+ preflight_linux_installed_binaries(
+ [runtime_dir / "llama-server", runtime_dir / "llama-quantize"],
+ install_dir,
+ host,
+ )
+ except Exception:
+ return False
expected_fingerprint = expected_install_fingerprint(
llama_tag = llama_tag,
release_tag = release_tag,
diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py
index cff800345a..158218062d 100644
--- a/studio/install_python_stack.py
+++ b/studio/install_python_stack.py
@@ -37,6 +37,19 @@ from backend.utils.wheel_utils import (
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
IS_MAC_INTEL = IS_MACOS and platform.machine() == "x86_64"
+IS_MAC_ARM = IS_MACOS and platform.machine() == "arm64"
+IS_LINUX = sys.platform.startswith("linux")
+# torchcodec ships wheels only for manylinux_2_28_x86_64,
+# macosx_12_0_arm64, and win_amd64 (visible in the 0.10.0 PyPI page).
+# Trying to install it on any other host fails the whole
+# extras-no-deps step. `unsloth studio update` does not have a
+# --no-torch flag, so on these hosts the audio extras must be
+# filtered out independent of the NO_TORCH env var.
+PLATFORM_LACKS_TORCHCODEC_WHEEL = (
+ (IS_LINUX and platform.machine() in {"aarch64", "arm64"})
+ or (IS_WINDOWS and platform.machine().lower() in {"arm64", "aarch64"})
+ or IS_MAC_INTEL
+)
# ── ROCm / AMD GPU support ─────────────────────────────────────────────────────
# Mapping from detected ROCm (major, minor) to the best PyTorch wheel tag on
@@ -423,6 +436,7 @@ def _infer_no_torch() -> bool:
NO_TORCH = _infer_no_torch()
+
# -- Verbosity control ----------------------------------------------------------
# By default the installer shows a minimal progress bar (one line, in-place).
# Set UNSLOTH_VERBOSE=1 in the environment to restore full per-step output:
@@ -448,6 +462,11 @@ LOCAL_DD_GITHUB_PLUGIN = (
SCRIPT_DIR / "backend" / "plugins" / "data-designer-github-repo-seed"
)
+# Apple Silicon: override mlx-vlm/mlx-lm's transformers pin (see overrides file).
+_MLX_OVERRIDES = SINGLE_ENV / "overrides-darwin-arm64.txt"
+if IS_MAC_ARM and _MLX_OVERRIDES.is_file():
+ os.environ.setdefault("UV_OVERRIDE", str(_MLX_OVERRIDES))
+
# -- Unicode-safe printing ---------------------------------------------
# On Windows the default console encoding can be a legacy code page
# (e.g. CP1252) that cannot represent Unicode glyphs such as ✅ or ❌.
@@ -597,7 +616,14 @@ WINDOWS_SKIP_PACKAGES = {"open_spiel", "triton_kernels"}
# Packages to skip when torch is unavailable (Intel Mac GGUF-only mode).
# These packages either *are* torch extensions or have unconditional
# ``Requires-Dist: torch`` in their published metadata, so installing
-# them would pull torch back into the environment.
+# them would pull torch back into the environment. ``librosa`` also
+# lives in this set even though it does not itself require torch:
+# upstream ``llvmlite`` dropped its macOS x86_64 wheel between 0.42.0
+# and 0.46.0+ (see https://pypi.org/project/llvmlite/0.47.0/#files --
+# only macosx_arm64 / manylinux / win_amd64 remain), so on Intel Mac
+# the librosa -> numba -> llvmlite chain triggers a from-source build
+# that fails inside CI and on the host without LLVM 14/15 headers.
+# Tracked separately in unslothai/unsloth#5046.
NO_TORCH_SKIP_PACKAGES = {
"torch-stoi",
"timm",
@@ -605,6 +631,7 @@ NO_TORCH_SKIP_PACKAGES = {
"torch-c-dlpack-ext",
"openai-whisper",
"transformers-cfg",
+ "librosa",
}
@@ -832,6 +859,14 @@ def pip_install(
if actual_req is not None and NO_TORCH and NO_TORCH_SKIP_PACKAGES:
actual_req = _filter_requirements(actual_req, NO_TORCH_SKIP_PACKAGES)
temp_reqs.append(actual_req)
+ if actual_req is not None and PLATFORM_LACKS_TORCHCODEC_WHEEL:
+ # Linux aarch64 / Windows ARM64 / Intel Mac have no torchcodec
+ # wheel. `unsloth studio update --local` does not pass
+ # --no-torch, so the NO_TORCH filter above does not fire; do
+ # the targeted skip independently so the audio extras step
+ # does not take down the whole update.
+ actual_req = _filter_requirements(actual_req, {"torchcodec"})
+ temp_reqs.append(actual_req)
req_args_pip: list[str] = []
req_args_uv: list[str] = []
if actual_req is not None:
@@ -962,6 +997,20 @@ def install_python_stack() -> int:
[sys.executable, "-m", "pip", "install", "--upgrade", "pip"],
)
+ # macOS arm64: install MLX stack at latest (UV_OVERRIDE relaxes the
+ # mlx-vlm / mlx-lm transformers pin -- set at module load).
+ if IS_MAC_ARM and not skip_base:
+ _progress("MLX stack (Apple Silicon)")
+ pip_install(
+ "Installing MLX stack (mlx + mlx-lm + mlx-vlm)",
+ "--no-cache-dir",
+ "--upgrade",
+ "mlx",
+ "mlx-metal",
+ "mlx-lm",
+ "mlx-vlm",
+ )
+
# 3. Core packages: unsloth-zoo + unsloth (or custom package name)
if skip_base:
pass
@@ -981,22 +1030,11 @@ def install_python_stack() -> int:
package_name,
"unsloth-zoo",
)
- # Pydantic ships its core as a separate compiled wheel
- # (pydantic-core), and pydantic's ``_ensure_pydantic_core_version``
- # checks the installed core matches the exact version pinned in
- # its own metadata. With ``--no-deps`` plus an unpinned
- # ``pydantic`` / ``pydantic-core`` pair in no-torch-runtime.txt,
- # pip resolved each to the newest available version and the two
- # drifted (pydantic 2.13.4 pins pydantic-core==2.46.4 today, but
- # pydantic-core 2.47.0 was the latest). On a fresh Windows venv
- # the next ``import pydantic`` raised ``SystemError: ...
- # incompatible with the current pydantic version``.
- #
- # Resolve them WITH deps in a focused pip call so pip picks a
- # compatible pair. pydantic's own deps are
- # ``annotated-types``, ``pydantic-core``, ``typing-extensions``,
- # ``typing-inspection`` -- none of which transitively pull
- # torch, so this is safe for the no-torch path.
+ # Resolve pydantic WITH deps so pip pins pydantic-core to the
+ # exact version pydantic's metadata declares. Under --no-deps
+ # alone pip picks the latest of each and trips pydantic's
+ # _ensure_pydantic_core_version check. Transitive deps are
+ # torch-free.
pip_install(
"Installing pydantic (with deps for compatible core)",
"--no-cache-dir",
diff --git a/studio/setup.ps1 b/studio/setup.ps1
index 16df87bbd5..8950627ae8 100644
--- a/studio/setup.ps1
+++ b/studio/setup.ps1
@@ -352,7 +352,10 @@ function Get-PytorchCudaTag {
# string. Plain 2>$null doesn't fully suppress stderr in PS 5.1 --
# ErrorRecord objects leak into $output and break the -match.
$output = & $smiExe 2>&1 | Out-String
- if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') {
+ # Newer NVIDIA drivers (e.g. 610.x on Windows) print
+ # "CUDA UMD Version: X.Y" instead of the legacy "CUDA Version: X.Y".
+ # Accept both spellings so we don't fall through to the cu126 default.
+ if ($output -match 'CUDA(?: UMD)? Version:\s+(\d+)\.(\d+)') {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
# PyTorch 2.10 offers: cu124, cu126, cu128, cu130
@@ -842,7 +845,9 @@ if ($HasNvidiaSmi) {
$DriverMaxCuda = $null
try {
$smiOut = & $NvidiaSmiExe 2>&1 | Out-String
- if ($smiOut -match "CUDA Version:\s+([\d]+)\.([\d]+)") {
+ # Newer NVIDIA drivers (e.g. 610.x) report the driver max CUDA as
+ # "CUDA UMD Version: X.Y" rather than "CUDA Version: X.Y"; accept both.
+ if ($smiOut -match "CUDA(?: UMD)? Version:\s+([\d]+)\.([\d]+)") {
$DriverMaxCuda = "$($Matches[1]).$($Matches[2])"
substep "driver supports up to CUDA $DriverMaxCuda"
}
diff --git a/studio/setup.sh b/studio/setup.sh
index c5beb7ebd3..e8fc8f6f13 100755
--- a/studio/setup.sh
+++ b/studio/setup.sh
@@ -671,6 +671,16 @@ elif [ "$_HOST_SYSTEM" = "Linux" ] \
&& [ "$_HOST_MACHINE" = "x86_64" ] \
&& [ "$_LINUX_HAS_GPU" = false ]; then
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
+elif [ "$_HOST_SYSTEM" = "Linux" ] \
+ && { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \
+ && [ "$_LINUX_HAS_GPU" = false ]; then
+ # Linux ARM64 (Ampere Altra, Raspberry Pi 5, GitHub `ubuntu-24.04-arm`,
+ # CPU-only Jetson rescue mode, ...). unslothai/llama.cpp only ships
+ # the Linux CUDA bundles, so without this branch the prebuilt
+ # resolver returns 0 attempts on every release and the installer
+ # falls all the way back to a source build. Upstream ggml-org ships
+ # llama-bNNNN-bin-ubuntu-arm64.tar.gz from at least b9072 onward.
+ _HELPER_RELEASE_REPO="ggml-org/llama.cpp"
else
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
fi
diff --git a/tests/python/test_construct_chat_template_validation.py b/tests/python/test_construct_chat_template_validation.py
new file mode 100644
index 0000000000..9ab68639c4
--- /dev/null
+++ b/tests/python/test_construct_chat_template_validation.py
@@ -0,0 +1,77 @@
+"""Negative-path validation tests for unsloth.chat_templates.construct_chat_template.
+
+Regression coverage for the str.find() / regex no-match guards added in
+PR #5763 follow-up: missing placeholders or unrecoverable two-example
+structures must raise RuntimeError with a clear message, not IndexError
+or AttributeError, and must never silently drop the last character via
+s[:-1].
+
+Uses a minimal fake tokenizer so the cases run on CPU-only CI without
+HF_TOKEN and without downloading a gated model. The validation paths
+exercised here fail before construct_chat_template reaches any heavy
+tokenizer interaction, so the stub stays small.
+"""
+
+import pytest
+
+from unsloth.chat_templates import construct_chat_template
+
+
+class _FakeTokenizer:
+ """Minimum surface construct_chat_template touches before the
+ validation guards fire."""
+
+ name_or_path = "fake/tokenizer"
+ eos_token = ""
+
+ def get_vocab(self):
+ return {"": 0}
+
+
+@pytest.mark.parametrize(
+ "template, expected_in_message",
+ [
+ ("only {INPUT} here, no output marker", "{OUTPUT}"),
+ ("only {OUTPUT} here, no input marker", "{INPUT}"),
+ ("neither sentinel here, just literal text", "{INPUT}"),
+ ("neither sentinel here, just literal text", "{OUTPUT}"),
+ ],
+)
+def test_missing_placeholder_in_chat_template_raises(template, expected_in_message):
+ with pytest.raises(RuntimeError) as exc_info:
+ construct_chat_template(
+ tokenizer = _FakeTokenizer(),
+ chat_template = template,
+ extra_eos_tokens = [""],
+ )
+ assert expected_in_message in str(exc_info.value)
+
+
+def test_single_pair_template_raises_clear_error_not_attribute_error():
+ """One {INPUT}/{OUTPUT} pair (rather than the required two) used to
+ crash with AttributeError on `found.group(1)` after the for-loop
+ broke without setting `found`. Must raise RuntimeError now."""
+ template = "user: {INPUT}\nassistant: {OUTPUT}\n"
+ with pytest.raises(RuntimeError):
+ construct_chat_template(
+ tokenizer = _FakeTokenizer(),
+ chat_template = template,
+ extra_eos_tokens = [""],
+ )
+
+
+def test_error_message_excerpt_is_bounded():
+ """Error messages must include a bounded excerpt of the offending
+ template, not dump arbitrarily large content into the traceback."""
+ huge = ("garbage " * 5000) + "{INPUT}" # ~40 KB, missing {OUTPUT}
+ with pytest.raises(RuntimeError) as exc_info:
+ construct_chat_template(
+ tokenizer = _FakeTokenizer(),
+ chat_template = huge,
+ extra_eos_tokens = [""],
+ )
+ msg = str(exc_info.value)
+ # Excerpt is repr-quoted and capped; total message should stay well
+ # under the template length.
+ assert len(msg) < 1000
+ assert "{OUTPUT}" in msg
diff --git a/tests/sh/test_get_torch_index_url.sh b/tests/sh/test_get_torch_index_url.sh
index 7235873f53..a9fafa5359 100755
--- a/tests/sh/test_get_torch_index_url.sh
+++ b/tests/sh/test_get_torch_index_url.sh
@@ -59,6 +59,29 @@ MOCK
echo "$_dir"
}
+# Helper: create a mock nvidia-smi that prints the new "CUDA UMD Version" header
+# layout used by newer NVIDIA drivers (e.g. 610.x on Windows). See issue #5812.
+make_mock_smi_umd() {
+ _dir=$(mktemp -d)
+ cat > "$_dir/nvidia-smi" < official/cpu" "https://download.pytorch.org/whl/c
_result=$(UNSLOTH_PYTORCH_MIRROR="https://mirror.example.com/whl/" run_func "none")
assert_eq "trailing slash stripped -> mirror/cpu" "https://mirror.example.com/whl/cpu" "$_result"
+# 29) "CUDA UMD Version: 13.3" header (newer NVIDIA driver layout, issue #5812)
+# -> cu130, not the cu126 fallback.
+_dir=$(make_mock_smi_umd "13.3")
+_result=$(run_func "$_dir")
+assert_eq "CUDA UMD Version 13.3 -> cu130" "https://download.pytorch.org/whl/cu130" "$_result"
+rm -rf "$_dir"
+
+# 30) "CUDA UMD Version: 12.8" header (newer layout on a 12.x driver) -> cu128
+_dir=$(make_mock_smi_umd "12.8")
+_result=$(run_func "$_dir")
+assert_eq "CUDA UMD Version 12.8 -> cu128" "https://download.pytorch.org/whl/cu128" "$_result"
+rm -rf "$_dir"
+
+# 31) "CUDA UMD Version: 11.8" header (newer layout on an older driver) -> cu118
+_dir=$(make_mock_smi_umd "11.8")
+_result=$(run_func "$_dir")
+assert_eq "CUDA UMD Version 11.8 -> cu118" "https://download.pytorch.org/whl/cu118" "$_result"
+rm -rf "$_dir"
+
rm -f "$_FUNC_FILE"
rm -rf "$_FAKE_SMI_DIR"
rm -rf "$_TOOLS_DIR"
diff --git a/tests/studio/_playwright_robust.py b/tests/studio/_playwright_robust.py
index 3deeb38cda..b190b2b3e1 100644
--- a/tests/studio/_playwright_robust.py
+++ b/tests/studio/_playwright_robust.py
@@ -436,6 +436,8 @@ def evaluate_fetch(
headers: dict[str, str] | None = None,
body: Any = None,
timeout_ms: int = 20_000,
+ transport_retries: int = 2,
+ transport_backoff_ms: int = 250,
) -> dict[str, Any]:
"""Run `fetch(url, opts)` inside the page with an AbortSignal deadline.
@@ -482,16 +484,47 @@ def evaluate_fetch(
}
}
"""
- return page.evaluate(
- js,
- {
- "url": url,
- "method": method,
- "headers": headers or {},
- "body": body_arg,
- "timeoutMs": int(timeout_ms),
- },
- )
+ payload = {
+ "url": url,
+ "method": method,
+ "headers": headers or {},
+ "body": body_arg,
+ "timeoutMs": int(timeout_ms),
+ }
+ # Bounded retry on transport failures only.
+ # status != 0 -> real HTTP response (incl. 4xx/5xx); propagate.
+ # AbortError -> caller's deadline; propagate.
+ # else (==0) -> stale-keepalive / "TypeError: Failed to fetch"
+ # on macos-14 right after auth rotations close
+ # existing sessions. Retry after backoff so the
+ # browser pool evicts the dead socket.
+ last: dict[str, Any] | None = None
+ attempts = max(1, int(transport_retries) + 1)
+ for attempt in range(attempts):
+ result = page.evaluate(js, payload)
+ last = result
+ try:
+ status = int(result.get("status") or 0)
+ except (TypeError, ValueError):
+ status = 0
+ if status != 0:
+ return result
+ err = str(result.get("error") or "")
+ if "AbortError" in err:
+ return result
+ if attempt < attempts - 1:
+ wait_ms = transport_backoff_ms * (2**attempt)
+ try:
+ sys.stderr.write(
+ f"[evaluate_fetch] {method} {url}: transport failure "
+ f"({attempt + 1}/{attempts}, err={err!r}); "
+ f"retrying in {wait_ms}ms\n"
+ )
+ sys.stderr.flush()
+ except Exception:
+ pass
+ time.sleep(wait_ms / 1000.0)
+ return last or {"status": 0, "body": None, "error": "no attempt made"}
# ─────────────────────────────────────────────────────────────────────
diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py
index ed988e081f..49ad6da177 100644
--- a/tests/studio/install/test_install_llama_prebuilt_logic.py
+++ b/tests/studio/install/test_install_llama_prebuilt_logic.py
@@ -2799,6 +2799,97 @@ def test_runtime_overlay_cannot_overwrite_main_archive_payload(
assert (release_dir / name).exists(), f"missing {name}"
+def test_linux_runtime_overlay_copies_llama_tool_impl_libraries(
+ tmp_path: Path,
+) -> None:
+ install_from_archives = INSTALL_LLAMA_PREBUILT.install_from_archives
+
+ work = tmp_path / "work"
+ install = tmp_path / "install"
+ archives = tmp_path / "archives"
+ work.mkdir()
+ install.mkdir()
+ archives.mkdir()
+
+ bundle = archives / "app-b9334-linux-x64-cuda13-newer.tar.gz"
+ with tarfile.open(bundle, "w:gz") as archive:
+ for name in (
+ "llama-cli",
+ "llama-server",
+ "llama-quantize",
+ "libllama-cli-impl.so",
+ "libllama-server-impl.so",
+ "libllama-quantize-impl.so",
+ "libllama-common.so",
+ "libllama.so",
+ "libggml.so",
+ "libggml-base.so",
+ "libmtmd.so",
+ "libggml-cpu-x64.so",
+ "libggml-cuda.so",
+ ):
+ payload = f"{name}\n".encode()
+ member = tarfile.TarInfo(name)
+ member.size = len(payload)
+ archive.addfile(member, io.BytesIO(payload))
+
+ import hashlib
+ import shutil as _shutil
+
+ bundle_sha = hashlib.sha256(bundle.read_bytes()).hexdigest()
+ choice = AssetChoice(
+ repo = "unslothai/llama.cpp",
+ tag = "b9334",
+ name = bundle.name,
+ url = f"https://example.com/{bundle.name}",
+ source_label = "published",
+ install_kind = "linux-cuda",
+ runtime_line = "cuda13",
+ expected_sha256 = bundle_sha,
+ )
+ host = 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 = (13, 0),
+ compute_caps = [],
+ visible_cuda_devices = None,
+ has_physical_nvidia = True,
+ has_usable_nvidia = True,
+ )
+
+ orig_download = INSTALL_LLAMA_PREBUILT.download_file_verified
+
+ def fake_download(url, target_path, *, expected_sha256 = None, label = None, **kw):
+ _shutil.copy2(bundle, target_path)
+ if expected_sha256:
+ actual = hashlib.sha256(Path(target_path).read_bytes()).hexdigest()
+ if actual != expected_sha256:
+ raise INSTALL_LLAMA_PREBUILT.PrebuiltFallback(
+ f"sha256 mismatch on {label}"
+ )
+
+ INSTALL_LLAMA_PREBUILT.download_file_verified = fake_download
+ try:
+ install_from_archives(choice, host, install, work)
+ finally:
+ INSTALL_LLAMA_PREBUILT.download_file_verified = orig_download
+
+ runtime_dir = install / "build" / "bin"
+ for name in (
+ "libllama-cli-impl.so",
+ "libllama-server-impl.so",
+ "libllama-quantize-impl.so",
+ ):
+ assert (runtime_dir / name).exists(), f"missing {name}"
+ assert not (runtime_dir / "llama-cli").exists()
+
+
def test_python_runtime_dirs_covers_cu13_and_library_bin(
monkeypatch, tmp_path: Path
) -> None:
diff --git a/tests/studio/install/test_rocm_support.py b/tests/studio/install/test_rocm_support.py
index 698625e59b..e6f1ae1c65 100644
--- a/tests/studio/install/test_rocm_support.py
+++ b/tests/studio/install/test_rocm_support.py
@@ -346,28 +346,26 @@ class TestRuntimePatterns:
patterns = runtime_patterns_for_choice(choice)
assert "llama-server" in patterns
assert "llama-quantize" in patterns
- # Upstream split entry code into ``libllama--impl.so``
- # shared libraries (b9261+). llama-server and llama-quantize
- # are NEEDED-linked against ``libllama-server-impl.so`` and
- # ``libllama-quantize-impl.so`` respectively with RUNPATH
- # ``$ORIGIN``, so the prebuilt overlay MUST copy them
- # alongside the binaries or ldd reports them missing and
- # preflight forces a source-build fallback.
- assert "libllama-*-impl.so*" in patterns
+ # Broad lib*.so* covers libllama, libggml, libmtmd, libggml-cpu-*,
+ # plus the libllama--impl.so split that ggml-org/llama.cpp
+ # #23462 introduced between b9279 and b9283.
+ assert "lib*.so*" in patterns
def test_linux_cuda_patterns(self):
choice = AssetChoice(
repo = "", tag = "", name = "", url = "", source_label = "", install_kind = "linux-cuda"
)
patterns = runtime_patterns_for_choice(choice)
- assert "libggml-cuda.so*" in patterns
+ # libggml-cuda.so is matched by lib*.so* now.
+ assert "lib*.so*" in patterns
def test_linux_rocm_patterns(self):
choice = AssetChoice(
repo = "", tag = "", name = "", url = "", source_label = "", install_kind = "linux-rocm"
)
patterns = runtime_patterns_for_choice(choice)
- assert "libggml-hip.so*" in patterns
+ # libggml-hip.so is matched by lib*.so* now.
+ assert "lib*.so*" in patterns
assert "llama-server" in patterns
def test_windows_hip_patterns(self):
@@ -380,7 +378,10 @@ class TestRuntimePatterns:
install_kind = "windows-hip",
)
patterns = runtime_patterns_for_choice(choice)
- assert "*.exe" in patterns
+ # Narrowed from "*.exe" to the two binaries Studio actually
+ # invokes, mirroring the Linux/macOS pattern style.
+ assert "llama-server.exe" in patterns
+ assert "llama-quantize.exe" in patterns
assert "*.dll" in patterns
def test_macos_patterns(self):
diff --git a/tests/studio/run_real_mlx_smoke.py b/tests/studio/run_real_mlx_smoke.py
index 27f682ee4e..dc3001fd99 100644
--- a/tests/studio/run_real_mlx_smoke.py
+++ b/tests/studio/run_real_mlx_smoke.py
@@ -390,7 +390,11 @@ def cmd_train(args) -> int:
)
if k in train_result
}
- assert len(losses_per_step) == 7, f"expected 7 logged steps, got {losses_per_step}"
+ # logging_steps=1 + max_steps=N -> N callbacks; track config so the
+ # gate auto-follows if max_steps is bumped again.
+ assert (
+ len(losses_per_step) == config.max_steps
+ ), f"expected {config.max_steps} logged steps, got {losses_per_step}"
for i, l in enumerate(losses_per_step):
# Allow exact 0.0: fp16 per-step loss underflows to 0.0 after
# the LoRA reaches loss=0 around step ~10 with this fixture +
diff --git a/tests/studio/test_composer_rtl_bidi_attribute.py b/tests/studio/test_composer_rtl_bidi_attribute.py
index a1af16d4fc..1e2f0d0cf4 100644
--- a/tests/studio/test_composer_rtl_bidi_attribute.py
+++ b/tests/studio/test_composer_rtl_bidi_attribute.py
@@ -26,7 +26,11 @@ def _block_around(src: str, anchor: str, radius: int = 600) -> str:
def test_main_composer_has_dir_auto():
- block = _block_around(THREAD_TSX.read_text(), 'aria-label="Message input"')
+ # PR #5784 rewrote the literal attribute into a JSX conditional
+ # (`aria-label={overlay ? "Image edit instructions" : "Message input"}`),
+ # so anchor on the inner string literal instead -- it survives both
+ # the old and new spellings.
+ block = _block_around(THREAD_TSX.read_text(), '"Message input"')
assert 'dir="auto"' in block, 'main composer is missing dir="auto"'
diff --git a/tests/test_tool_mask_zoo_compat.py b/tests/test_tool_mask_zoo_compat.py
new file mode 100644
index 0000000000..b6b68a7561
--- /dev/null
+++ b/tests/test_tool_mask_zoo_compat.py
@@ -0,0 +1,103 @@
+"""Compatibility checks for env/tool mask support with older unsloth_zoo."""
+
+from __future__ import annotations
+
+import ast
+import os
+import textwrap
+
+import pytest
+import torch
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
+RL_SOURCE_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl.py")
+RL_REPLACEMENTS_SOURCE_PATH = os.path.join(
+ REPO_ROOT, "unsloth", "models", "rl_replacements.py"
+)
+
+
+def _read(path: str) -> str:
+ with open(path, "r") as fh:
+ return fh.read()
+
+
+def _load_local_align_completion_tool_mask():
+ src = _read(RL_SOURCE_PATH)
+ tree = ast.parse(src)
+ for node in tree.body:
+ if isinstance(node, ast.If):
+ for item in node.body:
+ if (
+ isinstance(item, ast.FunctionDef)
+ and item.name == "align_completion_tool_mask"
+ ):
+ function_src = ast.get_source_segment(src, item)
+ break
+ else:
+ continue
+ break
+ else:
+ raise AssertionError("local align_completion_tool_mask fallback is missing")
+
+ calls = []
+
+ def align_logprobs_with_mask(logprob_tensor, completion_mask, pad_value = None):
+ calls.append((logprob_tensor, completion_mask, pad_value))
+ return torch.tensor(
+ [[1, 0, 1], [0, 1, 1]],
+ device = completion_mask.device,
+ dtype = logprob_tensor.dtype,
+ )
+
+ namespace = {
+ "torch": torch,
+ "align_logprobs_with_mask": align_logprobs_with_mask,
+ }
+ exec(textwrap.dedent(function_src), namespace)
+ return namespace["align_completion_tool_mask"], calls
+
+
+def test_rl_uses_optional_zoo_tool_mask_helper():
+ src = _read(RL_SOURCE_PATH)
+ assert 'RL_REPLACEMENTS.get("align_completion_tool_mask")' in src
+ assert 'RL_REPLACEMENTS["align_completion_tool_mask"]' not in src
+
+
+def test_local_tool_mask_fallback_is_only_old_zoo_compat_shim():
+ align_completion_tool_mask, calls = _load_local_align_completion_tool_mask()
+ completion_mask = torch.tensor(
+ [[1, 1, 0], [1, 1, 1]],
+ dtype = torch.float32,
+ )
+
+ assert align_completion_tool_mask(None, completion_mask) is completion_mask
+ assert calls == []
+
+ same_shape_tool_mask = torch.tensor([[1, 0, 1], [0, 1, 1]], dtype = torch.bool)
+ with pytest.raises(RuntimeError, match = "Please upgrade unsloth_zoo"):
+ align_completion_tool_mask(same_shape_tool_mask, completion_mask)
+
+
+def test_grpo_accumulated_loss_omits_none_tool_mask_for_old_zoo():
+ src = _read(RL_REPLACEMENTS_SOURCE_PATH)
+ assert "_grpo_accumulated_loss_kwargs = {}" in src
+ assert (
+ 'if tool_mask is not None:\n _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask'
+ in src
+ )
+ assert src.count("**_grpo_accumulated_loss_kwargs") == 2
+
+ accelerated_loss_start = src.find('if hasattr(self.args, "loss_type"):')
+ assert accelerated_loss_start != -1
+ accelerated_loss_body = src[
+ accelerated_loss_start : src.find(
+ 'if "train" in self._metrics:', accelerated_loss_start
+ )
+ ]
+ assert "tool_mask = tool_mask" not in accelerated_loss_body
+
+
+def test_rollout_output_patch_requires_real_tool_mask_symbol():
+ src = _read(RL_REPLACEMENTS_SOURCE_PATH)
+ assert 're.search(r"\\btool_mask\\b", function)' in src
+ assert 'output["tool_mask"]' in src
diff --git a/unsloth/chat_templates.py b/unsloth/chat_templates.py
index e8a34cbc60..956fcb2392 100644
--- a/unsloth/chat_templates.py
+++ b/unsloth/chat_templates.py
@@ -2461,17 +2461,40 @@ extra_eos_tokens = None,
f"{left_changed}"
)
except:
- ending = chat_template[chat_template.find("{OUTPUT}") + len("{OUTPUT}"):]
+ output_pos = chat_template.find("{OUTPUT}")
+ input_pos = chat_template.find("{INPUT}")
+ if output_pos == -1 or input_pos == -1:
+ missing = []
+ if input_pos == -1: missing.append("{INPUT}")
+ if output_pos == -1: missing.append("{OUTPUT}")
+ raise RuntimeError(
+ f"Unsloth: chat_template must contain {' and '.join(missing)} "
+ f"placeholder(s). Got: {chat_template[:200]!r}"
+ )
+ ending = chat_template[output_pos + len("{OUTPUT}"):]
ending = re.escape(ending)
find_text = "{INPUT}" + ending + "(.+?{OUTPUT}" + ending + ")"
response_part = re.findall(find_text, chat_template, flags = re.DOTALL | re.MULTILINE)
+ if len(response_part) == 0:
+ raise RuntimeError(
+ "Unsloth: Could not recover a two-example structure from chat_template. "
+ "Provide exactly two {INPUT}/{OUTPUT} pairs (and optionally {SYSTEM}). "
+ f"Got: {chat_template[:200]!r}"
+ )
response_part = response_part[0]
+ found = None
for j in range(1, len(response_part)):
try_find = re.escape(response_part[:j])
try: found = next(re.finditer("(" + try_find + ").+?\\{INPUT\\}", chat_template, flags = re.DOTALL | re.MULTILINE))
except: break
+ if found is None:
+ raise RuntimeError(
+ "Unsloth: Could not locate a separator between examples in chat_template. "
+ "Provide exactly two {INPUT}/{OUTPUT} pairs (and optionally {SYSTEM}). "
+ f"Got: {chat_template[:200]!r}"
+ )
separator = found.group(1)
response_start = chat_template.find(response_part)
@@ -2607,8 +2630,20 @@ extra_eos_tokens = None,
jinja_template = "{{ bos_token }}" + jinja_template
# Get instruction and output parts for train_on_inputs = False
- input_part = input_part [:input_part .find("{INPUT}")]
- output_part = output_part[:output_part.find("{OUTPUT}")]
+ input_idx = input_part .find("{INPUT}")
+ output_idx = output_part.find("{OUTPUT}")
+ if input_idx == -1:
+ raise RuntimeError(
+ f"Unsloth: The instruction section of the template must contain the "
+ f"'{{INPUT}}' placeholder. Section: {input_part[:200]!r}"
+ )
+ if output_idx == -1:
+ raise RuntimeError(
+ f"Unsloth: The response section of the template must contain the "
+ f"'{{OUTPUT}}' placeholder. Section: {output_part[:200]!r}"
+ )
+ input_part = input_part [:input_idx ]
+ output_part = output_part[:output_idx]
return modelfile, jinja_template, input_part, output_part
diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py
index 3b67c0f487..8965a88fae 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.5.6"
+__version__ = "2026.5.8"
__all__ = [
"SUPPORTS_BFLOAT16",
diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py
index b2e6a45aa0..7af2a7d136 100644
--- a/unsloth/models/llama.py
+++ b/unsloth/models/llama.py
@@ -2594,6 +2594,7 @@ class FastLlamaModel:
quant_state_dict, model_config, dtype, bnb_config
)
model.vllm_engine = llm
+ llm.shared_weights = True
model.fast_generate = model.vllm_engine.generate
model.fast_generate_batches = functools.partial(
generate_batches, model.vllm_engine
diff --git a/unsloth/models/rl.py b/unsloth/models/rl.py
index 7b7c3ac1a4..359716fda4 100644
--- a/unsloth/models/rl.py
+++ b/unsloth/models/rl.py
@@ -365,6 +365,22 @@ calculate_pad_tokens_in_prompt = RL_REPLACEMENTS["calculate_pad_tokens_in_prompt
create_completion_attention_mask = RL_REPLACEMENTS["create_completion_attention_mask"]
left_pack_padding = RL_REPLACEMENTS["left_pack_padding"]
align_logprobs_with_mask = RL_REPLACEMENTS["align_logprobs_with_mask"]
+align_completion_tool_mask = RL_REPLACEMENTS.get("align_completion_tool_mask")
+if align_completion_tool_mask is None:
+
+ def align_completion_tool_mask(
+ tool_mask: torch.Tensor,
+ completion_mask: torch.Tensor,
+ ) -> torch.Tensor:
+ if tool_mask is None:
+ return completion_mask
+ raise RuntimeError(
+ "env_mask/tool_mask GRPO requires an unsloth_zoo build whose "
+ "grpo_accumulated_loss handles tool_mask. Please upgrade "
+ "unsloth_zoo."
+ )
+
+
autotune_batch_and_chunks = RL_REPLACEMENTS["grpo_autotune_batch_and_chunks"]
sanitize_logprob = RL_REPLACEMENTS["sanitize_logprob"]
@@ -452,6 +468,7 @@ torch_compile_options = {{
{create_completion_attention_mask_code}
{left_pack_padding_code}
{align_logprobs_with_mask_code}
+{align_completion_tool_mask_code}
{autotune_batch_and_chunks_code}
{sanitize_logprob_code}
@@ -1312,7 +1329,9 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"):
"logging_nan_inf_filter": False,
"per_device_train_batch_size": 4,
"gradient_accumulation_steps": 2,
- "weight_decay": 0.01,
+ # LoRA decays A and B toward 0 so effective W = W_init + (alpha/r) * B @ A is pulled toward W_init, not 0 as in full FT.
+ # 0.001 keeps a small Frobenius prior |A|_F^2 + |B|_F^2 without measurably dragging the merged adapter back to base.
+ "weight_decay": 0.001,
"seed": 3407,
"optim": "adamw_8bit",
"learning_rate": 5e-05,
@@ -1575,6 +1594,7 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"):
)
left_pack_padding_code = inspect.getsource(left_pack_padding)
align_logprobs_with_mask_code = inspect.getsource(align_logprobs_with_mask)
+ align_completion_tool_mask_code = inspect.getsource(align_completion_tool_mask)
autotune_batch_and_chunks_code = inspect.getsource(autotune_batch_and_chunks)
sanitize_logprob_code = inspect.getsource(sanitize_logprob)
# Get final source code
@@ -1605,6 +1625,7 @@ def _patch_trl_rl_trainers_impl(trainer_file = "grpo_trainer"):
autotune_batch_and_chunks_code = autotune_batch_and_chunks_code,
left_pack_padding_code = left_pack_padding_code,
align_logprobs_with_mask_code = align_logprobs_with_mask_code,
+ align_completion_tool_mask_code = align_completion_tool_mask_code,
sanitize_logprob_code = sanitize_logprob_code,
)
@@ -1981,12 +2002,14 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
'GuidedDecodingParams(backend="outlines", regex=args.vllm_guided_decoding_regex) '
'if getattr(args, "vllm_guided_decoding_regex", None) is not None else None,',
)
- # Replace with our vLLM engine
+ # Replace with our vLLM engine when sharing weights
sampling_params = (
" " * 12
- + "self.llm = model.vllm_engine; self._last_loaded_step = 0; "
+ + "if getattr(getattr(model, 'vllm_engine', None), 'shared_weights', False): "
+ + "self.llm = model.vllm_engine; self._last_loaded_step = 0\n"
+ + " " * 12
+ sampling_params
- ) # Add spaces
+ )
# count the indentation of last line of sampling_params.
splitted_sampling_params = sampling_params.split("\n")
@@ -2019,14 +2042,27 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
)
if trl_version >= Version("0.18.0"):
- # Replace LLM init with already existing vLLM engine for colocate mode
- vllm_llm_init_pattern = r"self\.llm\s*=\s*LLM\(.*?\)*\)\s*?\n(?!,)"
- vllm_llm_replacement = "self.llm = model.vllm_engine\n"
+ # Guard LLM init - use existing vLLM engine when sharing weights,
+ # otherwise keep the original LLM() creation for sync/reload path
+ vllm_llm_init_pattern = (
+ r"(?P[ \t]*)self\.llm\s*=\s*LLM\(.*?\)*\)\s*?\n(?!,)"
+ )
+
+ def guard_llm_init(match):
+ indent = match.group("indent")
+ original = match.group(0)
+ return (
+ f"{indent}if getattr(getattr(model, 'vllm_engine', None), 'shared_weights', False):\n"
+ f"{indent} self.llm = model.vllm_engine\n"
+ f"{indent}else:\n"
+ f"{indent} {original.lstrip()}"
+ )
+
new_vllm_part = re.sub(
vllm_llm_init_pattern,
- vllm_llm_replacement,
+ guard_llm_init,
new_vllm_part,
- flags = re.DOTALL, # Ensure . matches newlines [[5]]
+ flags = re.DOTALL,
)
init = init.replace(vllm_part, new_vllm_part)
@@ -2095,7 +2131,7 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
source,
)
- # Replace self.llm.generate and self.llm.chat
+ # Replace self.llm.generate and self.llm.chat with lora_request (only when sharing weights)
if "CUDA_VISIBLE_DEVICES" in os.environ:
lora_name = (
trainer_file
@@ -2108,7 +2144,9 @@ def patch_functions(RLTrainer, trainer_file, RLTrainer_name, all_imports, import
r"(self\.llm\.(?:generate|chat)\([^\)]{1,})\)",
r"\1, lora_request = self.model.load_lora('"
+ lora_name
- + r", load_tensors = True))",
+ + r", load_tensors = True)"
+ + r" if getattr(self.llm, 'shared_weights', False)"
+ + r" else None)",
source,
)
# All these are to fix multiple commas before lora_request (in case the original code ends with something like ",)")
diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py
index 0f9a324d5b..31d54675c9 100644
--- a/unsloth/models/rl_replacements.py
+++ b/unsloth/models/rl_replacements.py
@@ -53,6 +53,23 @@ from ._utils import _get_inference_mode_context_manager
RL_EXTRA_ARGS = defaultdict(list)
RL_FUNCTIONS = defaultdict(list)
RL_PRE_ITEMS = defaultdict(list)
+
+
+def _unsloth_clear_stateful_mrope(model):
+ modules = getattr(model, "modules", None)
+ if modules is None:
+ return False
+
+ cleared = False
+ for module in modules():
+ if hasattr(module, "compute_3d_position_ids") and hasattr(
+ module, "rope_deltas"
+ ):
+ module.rope_deltas = None
+ cleared = True
+ return cleared
+
+
RL_CONFIG_CHANGES = defaultdict(list)
RL_METRICS_CHANGES = defaultdict(list)
RL_ADDITIONAL_FUNCTIONS = defaultdict(list)
@@ -613,39 +630,48 @@ def grpo_trainer__prepare_inputs(function_name, function):
RL_FUNCTIONS["grpo_trainer"].append(grpo_trainer__prepare_inputs)
-# Remove collective RPC of reload weights from generate
-# trl added reload weights (potentially for quantized models), we don't need it for our use case (LoRA primarily)
+# Guard reload_weights and sync_weights - skip when fast inference LoRA shares weights with vLLM
# https://github.com/huggingface/trl/commit/7856d3b1f6518601732f489883b341bb6dd36434#diff-964e6fd373aa93037604064cb2b822d7f8e2735e33f791065acf2c4c3552d393R1168-R1169
-def grpo_trainer__generate_single_turn(function_name, function):
- if function_name != "_generate_single_turn":
- return function
-
- # Remove the reload_weights collective RPC call from the generate function's source
- # function = function.replace('self.llm.collective_rpc("reload_weights")', "")
- # The regex below does the same thing but is more flexible and can handle single or double quotes
- # This is for older versions.
- function = re.sub(
- r"self\.llm\.collective_rpc\(\s*(['\"])reload_weights\1\s*\)",
- "",
- function,
+def _guard_vllm_sync_reload_for_shared_weights(function):
+ # Guard reload_weights - only call when not sharing weights with vLLM
+ reload_weights_pattern = re.compile(
+ r"^(?P[ \t]*)self\.llm\.collective_rpc\(\s*(['\"])reload_weights\2\s*\)\s*$",
+ re.MULTILINE,
)
- # Current TRL versions call vllm_generation.sync_weights() every step.
- # When Unsloth fast inference LoRA is active, weights are already shared.
+ def replace_reload_weights_line(match):
+ indent = match.group("indent")
+ return (
+ f"{indent}if not getattr(self.llm, 'shared_weights', False):\n"
+ f'{indent} self.llm.collective_rpc("reload_weights")\n'
+ )
+
+ function = reload_weights_pattern.sub(replace_reload_weights_line, function)
+
+ # Guard sync_weights - skip when sharing weights with vLLM
sync_weights_block = re.compile(
r"(?P[ \t]*)with profiling_context\(self,\s*(['\"])sync_weights\2\s*\):\n"
r"(?P=indent)[ \t]+self\.vllm_generation\.sync_weights\(\)\n",
re.MULTILINE,
)
- def remove_sync_weights_block(match):
+ def guard_sync_weights_block(match):
indent = match.group("indent")
return (
- f"{indent}# Unsloth fast inference LoRA shares weights with vLLM already.\n"
- f"{indent}# Skipping per-step vLLM sync_weights().\n"
+ f"{indent}if not getattr(getattr(self.vllm_generation, 'llm', None), 'shared_weights', False):\n"
+ f"{indent} with profiling_context(self, 'sync_weights'):\n"
+ f"{indent} self.vllm_generation.sync_weights()\n"
)
- function = sync_weights_block.sub(remove_sync_weights_block, function)
+ function = sync_weights_block.sub(guard_sync_weights_block, function)
+ return function
+
+
+def grpo_trainer__generate_single_turn(function_name, function):
+ if function_name != "_generate_single_turn":
+ return function
+
+ function = _guard_vllm_sync_reload_for_shared_weights(function)
# TRL 0.24.0-0.25.1 truncation regression fix
#
@@ -693,6 +719,16 @@ def grpo_trainer__generate_single_turn(function_name, function):
RL_FUNCTIONS["grpo_trainer"].append(grpo_trainer__generate_single_turn)
+def grpo_trainer__generate(function_name, function):
+ if function_name != "_generate":
+ return function
+
+ return _guard_vllm_sync_reload_for_shared_weights(function)
+
+
+RL_FUNCTIONS["grpo_trainer"].append(grpo_trainer__generate)
+
+
# Fix incorrect special tokens handling and truncation in older TRL versions
def grpo_trainer__generate_and_score_completions(function_name, function):
if function_name != "_generate_and_score_completions":
@@ -878,6 +914,18 @@ def grpo_trainer__generate_and_score_completions(function_name, function):
)
function = function.replace(string_to_find, replacement_string)
+ _generate_return = """ ) = self._generate(prompts)"""
+ if _generate_return in function and "_unsloth_clear_stateful_mrope" not in function:
+ function = function.replace(
+ _generate_return,
+ _generate_return
+ + """
+
+ _unsloth_clear_stateful_mrope(
+ self.accelerator.unwrap_model(self.model, keep_fp32_wrapper = False)
+ )""",
+ )
+
if "wake_up()" not in function:
# Sleep functionality has been added to trl in v0.23.0. We do not want to redo this.
# https://github.com/huggingface/trl/commit/edbe8234bc7e528f72ac76607de9d3e4753e2709
@@ -946,6 +994,14 @@ def grpo_trainer__generate_and_score_completions(function_name, function):
)
function = function.replace(_save_search, _save_replace)
+ if re.search(r"\btool_mask\b", function) and 'output["tool_mask"]' not in function:
+ function = function.replace(
+ " return output",
+ " if tool_mask is not None:\n"
+ ' output["tool_mask"] = tool_mask\n'
+ " return output",
+ )
+
return function
@@ -1482,6 +1538,7 @@ RL_PRE_ITEMS["grpo_trainer"].append(
)
RL_PRE_ITEMS["grpo_trainer"].append(inspect.getsource(_unsloth_get_mm_token_id))
RL_PRE_ITEMS["grpo_trainer"].append(inspect.getsource(_unsloth_fix_mm_token_type_ids))
+RL_PRE_ITEMS["grpo_trainer"].append(inspect.getsource(_unsloth_clear_stateful_mrope))
RL_PRE_ITEMS["grpo_trainer"].append(inspect.getsource(grpo_compute_loss))
RL_PRE_ITEMS["grpo_trainer"].append(inspect.getsource(UnslothEfficientGRPO))
RL_PRE_ITEMS["grpo_trainer"].append(inspect.getsource(grpo_accumulated_loss))
@@ -1523,6 +1580,7 @@ def grpo_trainer_compute_loss(function_name, function):
mm_token_type_ids = inputs.get("mm_token_type_ids", None)
num_items_in_batch = inputs.get("num_items_in_batch", None)
sampling_per_token_logps = inputs.get("sampling_per_token_logps", None)
+ tool_mask = inputs.get("tool_mask", None)
current_gradient_accumulation_steps = self.current_gradient_accumulation_steps
num_processes = self.accelerator.num_processes
@@ -1598,6 +1656,16 @@ def grpo_trainer_compute_loss(function_name, function):
max_left_pad = inputs.get("max_left_pad", 0)
if per_token_logps is not None:
+ loss_mask = completion_mask
+ if tool_mask is not None:
+ if tool_mask.shape != completion_mask.shape:
+ raise ValueError(
+ "tool_mask/env_mask must have the same shape as completion_mask"
+ )
+ loss_mask = completion_mask * tool_mask.to(
+ device = completion_mask.device,
+ dtype = completion_mask.dtype,
+ )
(
loss,
completion_length,
@@ -1612,7 +1680,7 @@ def grpo_trainer_compute_loss(function_name, function):
old_logps,
sampling_per_token_logps,
input_ids,
- completion_mask,
+ loss_mask,
self.beta,
advantages,
pixel_values = pixel_values,
@@ -1662,6 +1730,28 @@ def grpo_trainer_compute_loss(function_name, function):
"unsloth_zoo (see https://github.com/unslothai/unsloth-zoo/pull/613)."
)
self._unsloth_grpo_zoo_checked = True
+ if tool_mask is not None and not getattr(
+ self, "_unsloth_grpo_tool_mask_zoo_checked", False
+ ):
+ _supports_tool_mask = (
+ "tool_mask" in inspect.signature(grpo_accumulated_loss).parameters
+ )
+ if not _supports_tool_mask:
+ try:
+ _zoo_src = inspect.getsource(grpo_accumulated_loss)
+ except (TypeError, OSError):
+ _zoo_src = ""
+ _supports_tool_mask = "tool_mask" in _zoo_src
+ if not _supports_tool_mask:
+ raise RuntimeError(
+ "env_mask/tool_mask GRPO requires an unsloth_zoo build whose "
+ "grpo_accumulated_loss handles tool_mask. Please upgrade "
+ "unsloth_zoo."
+ )
+ self._unsloth_grpo_tool_mask_zoo_checked = True
+ _grpo_accumulated_loss_kwargs = {}
+ if tool_mask is not None:
+ _grpo_accumulated_loss_kwargs["tool_mask"] = tool_mask
if hasattr(self.args, "loss_type"):
(
loss,
@@ -1703,6 +1793,7 @@ def grpo_trainer_compute_loss(function_name, function):
sampling_per_token_logps = sampling_per_token_logps,
token_type_ids = token_type_ids,
mm_token_type_ids = mm_token_type_ids,
+ **_grpo_accumulated_loss_kwargs,
)
else:
# to ensure backwards compatibility with trl 0.15.2 and maybe even 0.17
@@ -1728,6 +1819,7 @@ def grpo_trainer_compute_loss(function_name, function):
attention_mask = attention_mask,
token_type_ids = token_type_ids,
mm_token_type_ids = mm_token_type_ids,
+ **_grpo_accumulated_loss_kwargs,
)
)
if "train" in self._metrics:
@@ -1927,7 +2019,7 @@ RL_METRICS_CHANGES["grpo_trainer"].append(grpo_trainer_metrics)
def openenv_vllm_reload_weights():
# This function patches the trl openenv generate_rollout_completions function to:
- # 1. Remove the reload_weights call (unsloth handles weight reloading)
+ # 1. Guard the reload_weights call (skip when sharing weights with vLLM)
# 2. Fix wake_up call to be compatible with unsloth (remove tags to wake everything)
#
# The issue: TRL's wake_up(tags=["kv_cache"]) only wakes kv_cache, leaving is_sleeping=True
@@ -1984,8 +2076,20 @@ def openenv_vllm_reload_weights():
src = textwrap.dedent(src)
original_src = src
- # Remove the reload_weights call - unsloth handles this differently
- src = re.sub(r'.*\.collective_rpc\(\s*([\'"])reload_weights\1\s*\).*\n?', "", src)
+ reload_weights_pattern = re.compile(
+ r"^(?P[ \t]*)(?P\S+)\.collective_rpc\(\s*(['\"])reload_weights\3\s*\)\s*$",
+ re.MULTILINE,
+ )
+
+ def replace_reload_weights(match):
+ indent = match.group("indent")
+ obj = match.group("obj")
+ return (
+ f"{indent}if not getattr({obj}, 'shared_weights', False):\n"
+ f'{indent} {obj}.collective_rpc("reload_weights")\n'
+ )
+
+ src = reload_weights_pattern.sub(replace_reload_weights, src)
# Change wake_up(tags=["kv_cache"]) to wake_up() - wake everything to set is_sleeping=False
# This prevents double wake_up issues. Unsloth's allocator skips weights anyway.
@@ -2075,7 +2179,9 @@ def vllm_generation_init_patch():
f"{indent}if hasattr(model, 'vllm_engine'):\n"
f"{indent} # Unsloth already inits vLLM in fast inference mode. Do not redo :)\n"
f"{indent} self.llm = model.vllm_engine\n"
- f"{indent} self.unsloth_fast_inference_lora = True\n"
+ f"{indent} self.unsloth_fast_inference_lora = getattr(self.llm, 'shared_weights', False)\n"
+ f"{indent} if getattr(self.llm, 'shared_weights', False) and hasattr(model, 'load_lora'):\n"
+ f"{indent} self._unsloth_load_lora = model.load_lora\n"
f"{indent}else:\n" + textwrap.indent(llm_block, indent + " ")
)
@@ -2097,8 +2203,11 @@ def vllm_generation_init_patch():
def replace_sync_weights(match):
body = match.group("body")
+ # Chain getattr so server mode (where self.llm is not set) does
+ # not raise AttributeError before the default kicks in.
guard = (
- " if getattr(self, 'unsloth_fast_inference_lora', False):\n"
+ " if getattr(getattr(self, 'llm', None), 'shared_weights', False) or "
+ "getattr(self, 'unsloth_fast_inference_lora', False):\n"
" # Unsloth fast inference LoRA shares weights with vLLM already.\n"
" return\n\n"
)
@@ -2119,7 +2228,12 @@ def vllm_generation_init_patch():
def replace_reload_weights(match):
indent = match.group("indent")
- return f'{indent}pass # self.llm.collective_rpc("reload_weights")'
+ # Chain getattr so server mode (no self.llm) is safe here too.
+ return (
+ f"{indent}if not (getattr(getattr(self, 'llm', None), 'shared_weights', False) or "
+ f"getattr(self, 'unsloth_fast_inference_lora', False)):\n"
+ f'{indent} self.llm.collective_rpc("reload_weights")'
+ )
patched_src, num_replacements = pattern.subn(
replace_reload_weights, src, count = 1
@@ -2128,25 +2242,39 @@ def vllm_generation_init_patch():
raise RuntimeError(
"Unsloth: Warning - regex did not match, VLLMGeneration.generate patch may have failed"
)
+
+ # Inject lora_request when sharing weights (vLLM needs the adapter)
+ lora_generate_pattern = re.compile(
+ r"(self\.llm\.generate\([^\)]+)\)",
+ )
+
+ def inject_lora_request(match):
+ return (
+ f"{match.group(1)}, lora_request="
+ f"self._unsloth_load_lora('vllm_gen_lora', load_tensors=True) "
+ f"if hasattr(self, '_unsloth_load_lora') else None)"
+ )
+
+ patched_src = lora_generate_pattern.sub(inject_lora_request, patched_src)
return patched_src
try:
init_patched = patch_vllm_generation_method(
"_init_vllm",
patch_init_vllm,
- "self.unsloth_fast_inference_lora = True",
+ "self.unsloth_fast_inference_lora = getattr(self.llm, 'shared_weights', False)",
"init_vllm",
)
sync_patched = patch_vllm_generation_method(
"sync_weights",
patch_sync_weights,
- "if getattr(self, 'unsloth_fast_inference_lora', False):",
+ "if getattr(getattr(self, 'llm', None), 'shared_weights', False) or getattr(self, 'unsloth_fast_inference_lora', False):",
"sync_weights",
)
generate_patched = patch_vllm_generation_method(
"generate",
patch_generate,
- 'pass # self.llm.collective_rpc("reload_weights")',
+ "if not (getattr(getattr(self, 'llm', None), 'shared_weights', False) or getattr(self, 'unsloth_fast_inference_lora', False)):",
"generate",
)
except RuntimeError as e:
diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py
index 73ef1db7e3..6c4842596f 100644
--- a/unsloth/models/vision.py
+++ b/unsloth/models/vision.py
@@ -1079,6 +1079,7 @@ class FastBaseModel:
is_vision_model = is_vlm,
)
model.vllm_engine = llm
+ llm.shared_weights = True
model.fast_generate = model.vllm_engine.generate
model.fast_generate_batches = functools.partial(
generate_batches, model.vllm_engine
diff --git a/unsloth_cli/__init__.py b/unsloth_cli/__init__.py
index 65834b3b9d..c8ec4c66c0 100644
--- a/unsloth_cli/__init__.py
+++ b/unsloth_cli/__init__.py
@@ -1,6 +1,9 @@
# 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 os.path as _osp
+import sys as _sys
+
import typer
from importlib.metadata import version as package_version, PackageNotFoundError
@@ -8,7 +11,19 @@ from importlib.metadata import version as package_version, PackageNotFoundError
from unsloth_cli.commands.train import train
from unsloth_cli.commands.inference import inference
from unsloth_cli.commands.export import export, list_checkpoints
-from unsloth_cli.commands.studio import run as studio_run, studio_app
+from unsloth_cli.commands.studio import (
+ run as studio_run,
+ studio_app,
+ _expand_attached_np_short,
+)
+
+
+# Canonicalise `-np` only under the `unsloth` console-script;
+# third-party scripts that import unsloth_cli keep their argv intact.
+_entry_base = _osp.basename(_sys.argv[0]).lower() if _sys.argv else ""
+if _entry_base in {"unsloth", "unsloth.exe"}:
+ _expand_attached_np_short()
+del _entry_base
def show_version(value: bool):
@@ -47,9 +62,8 @@ app.command()(export)
app.command("list-checkpoints")(list_checkpoints)
app.add_typer(studio_app, name = "studio", help = "Unsloth Studio commands.")
-# Top-level alias: `unsloth run ...` is equivalent to `unsloth studio run ...`.
-# Same context_settings as the studio_app registration so unknown flags
-# still pass through to llama-server.
+# Top-level `unsloth run` aliases `unsloth studio run`; same context
+# so unknown flags still pass through to llama-server.
app.command(
"run",
context_settings = {
diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py
index 67395a8378..edb8ea2cf4 100644
--- a/unsloth_cli/commands/studio.py
+++ b/unsloth_cli/commands/studio.py
@@ -206,6 +206,87 @@ def _find_setup_script() -> Optional[Path]:
return None
+# Mirror in studio/backend/run.py argparse + backend denylist test;
+# bumping the cap in one place only desyncs.
+_PARALLEL_MIN = 1
+_PARALLEL_MAX = 64
+_PARALLEL_DEFAULT_RUN = 4 # pre-PR hardcoded for `unsloth studio run`
+_PARALLEL_DEFAULT_PLAIN = 1 # pre-PR effective for plain `unsloth studio`
+
+
+def _iter_editable_studio_source_roots(venv_dir: Path):
+ """Yield repo roots from setuptools `__editable___*_finder.py` files in
+ *venv_dir*'s site-packages whose MAPPING includes a `studio` entry.
+
+ Returns the parent dir of the mapped `studio` package (i.e. the repo
+ root), so callers can append `/studio/...` to reach any subdir.
+ """
+ import ast
+ import re
+
+ for sp_pattern in ("lib/python*/site-packages", "Lib/site-packages"):
+ for sp in venv_dir.glob(sp_pattern):
+ for finder in sp.glob("__editable___*_finder.py"):
+ try:
+ src = finder.read_text(encoding = "utf-8")
+ except OSError:
+ continue
+ # Tolerate single- or multi-line dict literals; [^}]* still
+ # rejects nested dicts, which the setuptools template never
+ # emits for editable installs.
+ m = re.search(
+ r"^MAPPING\s*(?::[^=]*)?=\s*(\{[^}]*\})", src, re.M | re.S
+ )
+ if not m:
+ continue
+ try:
+ mapping = ast.literal_eval(m.group(1))
+ except (SyntaxError, ValueError):
+ continue
+ # Defensive: literal_eval can return a set / list / None if the
+ # matched literal is not a dict (regex captures `{...}`).
+ if not isinstance(mapping, dict):
+ continue
+ studio_pkg = mapping.get("studio")
+ if studio_pkg:
+ yield Path(studio_pkg).parent
+
+
+def _find_frontend_dist() -> Optional[Path]:
+ """Locate a built `studio/frontend/dist` (containing index.html).
+
+ Probes (in order): package-local default, installer venv site-packages,
+ editable source roots referenced from the installer venv. Returns None
+ if nothing servable is found, so callers can decide to error or proceed
+ in `--api-only` mode.
+
+ Fixes the silent 404 when another `unsloth` on PATH shadows the
+ installer's binary and points `_PACKAGE_ROOT` at a site-packages copy
+ that never received a vite build.
+ """
+ candidates: List[Path] = [_PACKAGE_ROOT / "studio" / "frontend" / "dist"]
+ venv_dir = STUDIO_HOME / "unsloth_studio"
+ for pattern in (
+ "lib/python*/site-packages/studio/frontend/dist",
+ "Lib/site-packages/studio/frontend/dist",
+ ):
+ candidates.extend(venv_dir.glob(pattern))
+ for repo_root in _iter_editable_studio_source_roots(venv_dir):
+ candidates.append(repo_root / "studio" / "frontend" / "dist")
+ seen: set[Path] = set()
+ for c in candidates:
+ try:
+ resolved = c.resolve()
+ except OSError:
+ resolved = c
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ if (c / "index.html").is_file():
+ return c
+ return None
+
+
# ── helpers for `unsloth studio run` ────────────────────────────────
@@ -514,14 +595,38 @@ def studio_default(
"--api-only",
help = "Run API server only, no frontend serving (for Tauri desktop app)",
),
+ parallel: int = typer.Option(
+ _PARALLEL_DEFAULT_PLAIN,
+ "--parallel",
+ "--n-parallel",
+ min = _PARALLEL_MIN,
+ max = _PARALLEL_MAX,
+ help = (
+ f"llama-server parallel decode slots ({_PARALLEL_MIN}..{_PARALLEL_MAX}). "
+ f"Default {_PARALLEL_DEFAULT_PLAIN}; `unsloth studio run` "
+ f"defaults to {_PARALLEL_DEFAULT_RUN}."
+ ),
+ ),
):
"""Launch the Unsloth Studio server."""
- # Runs before any subcommand; covers run/setup/update/etc in one place.
+ # Runs before every subcommand (run/setup/update/...).
_ensure_studio_env_exported()
if ctx.invoked_subcommand is not None:
+ # Typer doesn't forward parent options to subcommands, so
+ # `unsloth studio --parallel N run ...` would silently drop N.
+ if parallel != _PARALLEL_DEFAULT_PLAIN:
+ typer.echo(
+ f"Error: --parallel on `unsloth studio` applies to the "
+ f"plain-server path only. For `unsloth studio "
+ f"{ctx.invoked_subcommand}`, put the flag after the "
+ f"subcommand: `unsloth studio {ctx.invoked_subcommand} "
+ f"--parallel {parallel} ...`",
+ err = True,
+ )
+ raise typer.Exit(2)
return
- # Always use the studio venv if it exists and we're not already in it
+ # Use the studio venv if it exists and we aren't already in it.
studio_venv_dir = STUDIO_HOME / "unsloth_studio"
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
@@ -538,16 +643,23 @@ def studio_default(
host,
"--port",
str(port),
+ "--parallel",
+ str(parallel),
]
- if frontend:
- args.extend(["--frontend", str(frontend)])
+ # Resolve frontend explicitly so the spawned run.py uses a real
+ # built dist regardless of where its __file__ lands. Skip in
+ # --api-only (no UI served).
+ resolved_frontend = frontend
+ if resolved_frontend is None and not api_only:
+ resolved_frontend = _find_frontend_dist()
+ if resolved_frontend is not None:
+ args.extend(["--frontend", str(resolved_frontend)])
if silent:
args.append("--silent")
if api_only:
args.append("--api-only")
- # On Windows, os.execvp() spawns a child but the parent lingers,
- # so Ctrl+C only kills the parent leaving the child orphaned.
- # Use subprocess.run() on Windows so the parent waits for the child.
+ # On Windows os.execvp keeps the parent alive, so Ctrl+C
+ # would orphan the child; use Popen+wait instead.
if sys.platform == "win32":
import subprocess as _sp
@@ -555,7 +667,7 @@ def studio_default(
try:
rc = proc.wait()
except KeyboardInterrupt:
- # Child has its own signal handler — let it finish
+ # Child handles its own signal; let it finish.
rc = proc.wait()
if rc != 0:
typer.echo(
@@ -582,7 +694,13 @@ def studio_default(
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}")
- run_kwargs = dict(host = host, port = port, silent = silent, api_only = api_only)
+ run_kwargs = dict(
+ host = host,
+ port = port,
+ silent = silent,
+ api_only = api_only,
+ llama_parallel_slots = parallel,
+ )
if frontend is not None:
run_kwargs["frontend_path"] = frontend
run_server(**run_kwargs)
@@ -591,8 +709,8 @@ def studio_default(
try:
if _shutdown_event is not None:
- # NOTE: Event.wait() without a timeout blocks at the C level
- # on Linux, preventing Python from delivering SIGINT (Ctrl+C).
+ # Event.wait() with no timeout blocks at C-level on Linux
+ # and swallows SIGINT; loop with a 1s timeout instead.
while not _shutdown_event.is_set():
_shutdown_event.wait(timeout = 1)
else:
@@ -609,21 +727,15 @@ def studio_default(
def _split_repo_variant(model_arg: str) -> tuple[str, Optional[str]]:
- """Split ``org/name:variant`` HF-style identifiers into (repo, variant).
-
- Mirrors llama.cpp's ``-hf :`` convention so users can
- write ``unsloth/gpt-oss-20b-GGUF:UD-Q4_K_XL`` instead of passing
- ``--gguf-variant`` separately. Local paths (absolute, ``./``,
- ``~/``, Windows drive letters) and identifiers without a ``:``
- suffix are returned verbatim.
- """
+ """Split ``org/name:variant`` into ``(repo, variant)``; mirrors
+ llama.cpp's ``-hf :``. Local paths, Windows drives,
+ and ids without ``:`` pass through verbatim."""
s = model_arg.strip()
if not s:
return s, None
if s.startswith(("/", "./", "../", "~")) or s == ".":
return s, None
- # Windows drive letter (e.g. "C:\\path" or "C:/path") -- the colon
- # here is a path separator, not a variant suffix.
+ # Windows drive letter (e.g. "C:\path"): colon is a path separator.
if len(s) >= 2 and s[1] == ":" and s[0].isalpha():
return s, None
if ":" not in s:
@@ -631,13 +743,80 @@ def _split_repo_variant(model_arg: str) -> tuple[str, Optional[str]]:
repo, _, variant = s.rpartition(":")
if not repo or not variant:
return s, None
- # A real quant label has no slashes; ``foo:bar/baz`` is not
- # ``repo:variant`` syntax.
+ # Quant labels never contain a slash; `foo:bar/baz` isn't repo:variant.
if "/" in variant:
return s, None
return repo, variant
+def _expand_attached_np_short() -> None:
+ # Click clusters `-np8` as `-n -p 8` (-p = --port), dropping the
+ # parallel value. Split to `-np ` so typer's alias matches.
+ # Stops at `--`; accepts signed and digit-prefix-junk forms so
+ # typer can report a clean error against `-np`. Kept in lockstep
+ # with the backend `_flag_name` recogniser.
+ i = 0
+ while i < len(sys.argv):
+ tok = sys.argv[i]
+ if tok == "--":
+ break
+ if len(tok) > 3 and tok.startswith("-np") and tok[3] != "=":
+ suffix = tok[3:]
+ first_numeric = suffix[0].isdigit() or (
+ len(suffix) > 1 and suffix[0] in {"-", "+"} and suffix[1].isdigit()
+ )
+ if first_numeric:
+ sys.argv[i : i + 1] = ["-np", suffix]
+ i += 2
+ continue
+ i += 1
+
+
+def _consume_legacy_short_aliases(
+ args: List[str],
+ aliases: tuple[str, ...],
+ current: Optional[str],
+ canonical: str,
+) -> tuple[Optional[str], List[str]]:
+ """Pop exact-match legacy shorts (`-m`/`-hfr`/`-f`) from args;
+ leave clusters (`-mg`/`-fa`/...) for the llama-server tail. Inline
+ `-x=value` form also accepted."""
+ out: List[str] = []
+ value = current
+ i, n = 0, len(args)
+ while i < n:
+ tok = args[i]
+ if tok == "--": # end of options; tail is raw payload.
+ out.extend(args[i:])
+ break
+ name, sep, inline = tok.partition("=")
+ if name not in aliases:
+ out.append(tok)
+ i += 1
+ continue
+ if value is not None:
+ raise typer.BadParameter(
+ f"{name} conflicts with {canonical} already provided"
+ )
+ if sep:
+ if inline == "": # `-m=` would become --model '' (Path('')='.').
+ raise typer.BadParameter(f"{name} requires a non-empty value")
+ value = inline
+ i += 1
+ elif i + 1 < n:
+ nxt = args[i + 1]
+ # `--long` is unambiguously a flag; single-dash `-x` may be a path.
+ if nxt.startswith("--") and nxt != "--":
+ raise typer.BadParameter(
+ f"{name} expects a value but got the flag {nxt}"
+ )
+ value = nxt
+ i += 2
+ else:
+ raise typer.BadParameter(f"{name} requires a value")
+ return value, out
+
+
@studio_app.command(
context_settings = {
"allow_extra_args": True,
@@ -646,17 +825,18 @@ def _split_repo_variant(model_arg: str) -> tuple[str, Optional[str]]:
)
def run(
ctx: typer.Context,
- model: str = typer.Option(
- ...,
+ model: Optional[str] = typer.Option(
+ None,
"--model",
- "-m",
"-hf",
- "-hfr",
"--hf-repo",
+ # `-m` / `-hfr` removed (Click would cluster `-mg`/`-md`/...).
+ # Exact-match `-m`/`-hfr` still work via the legacy shim below.
+ # `-hf` stays (multi-char shorts don't cluster).
help = (
"Model path or HF repo. Accepts llama.cpp-style "
- "`org/repo:variant` syntax. The `-hf` / `--hf-repo` aliases "
- "match llama-server's spelling."
+ "`org/repo:variant` syntax. `-hf` / `--hf-repo` match "
+ "llama-server's spelling."
),
),
gguf_variant: Optional[str] = typer.Option(
@@ -671,7 +851,8 @@ def run(
),
port: int = typer.Option(8888, "--port", "-p"),
host: str = typer.Option("127.0.0.1", "--host", "-H"),
- frontend: Optional[Path] = typer.Option(None, "--frontend", "-f"),
+ # `-f` removed (clustered `-fa`/`-fit*`); studio_default keeps it.
+ frontend: Optional[Path] = typer.Option(None, "--frontend"),
silent: bool = typer.Option(False, "--silent", "-q"),
enable_tools: Optional[bool] = typer.Option(
None,
@@ -687,26 +868,65 @@ def run(
"-y",
help = "Skip the 0.0.0.0 + --enable-tools confirmation prompt.",
),
+ parallel: int = typer.Option(
+ _PARALLEL_DEFAULT_RUN,
+ "--parallel",
+ "--n-parallel",
+ "-np",
+ min = _PARALLEL_MIN,
+ max = _PARALLEL_MAX,
+ help = (
+ "llama-server parallel decode slots. N requests share one "
+ "loaded model; each slot gets ctx/N KV cache. Default "
+ f"{_PARALLEL_DEFAULT_RUN} (pre-PR hardcoded value)."
+ ),
+ ),
):
- """Start Studio, load a model, and print an API key -- one-liner server.
+ """Start Studio, load a model, print an API key -- one-liner server.
- Any flag this command does not recognize is forwarded verbatim to
- the underlying llama-server (GGUF only). Studio-managed flags
- (--port, -c / --ctx-size, --api-key, -ngl, --jinja, --flash-attn,
- --no-context-shift, model-identity flags, ...) are rejected with
- HTTP 400.
+ Unknown flags pass through to llama-server (GGUF only). Studio
+ rejects managed flags with HTTP 400: model identity, network
+ (--host/--port/--path/--api-prefix/--reuse-port), auth/TLS
+ (--api-key/--ssl-*), single-model UI (--ui/--models-*/--webui),
+ and parallel slots (use --parallel above). Full denylist in
+ studio/backend/core/inference/llama_server_args.py. Other knobs
+ (-c, -ngl, --jinja, --flash-attn, -t, ...) pass through and
+ last-wins-override Studio's auto-set value.
Example:
unsloth studio run --model unsloth/Qwen3-1.7B-GGUF --gguf-variant UD-Q4_K_XL
- unsloth studio run --model unsloth/Qwen3-1.7B-GGUF --top-k 20 --seed 42
+ unsloth studio run --model unsloth/Qwen3-1.7B-GGUF --top-k 20 --seed 42 --parallel 8
unsloth studio run --model some-model --chat-template-file /path/to/tpl.jinja
"""
extra_llama_args: List[str] = list(ctx.args) if ctx.args else []
- # ── 0. Parse llama.cpp-style ``repo:variant`` syntax in --model. ───
- # Lets users write ``--model unsloth/foo-GGUF:UD-Q4_K_XL`` instead
- # of pairing ``--model`` with ``--gguf-variant``. If both are given
- # and disagree, fail loudly instead of silently picking one.
+ # Promote legacy exact `-m`/`-hfr`/`-f` back into typer params;
+ # clusters stay in extras.
+ model, extra_llama_args = _consume_legacy_short_aliases(
+ extra_llama_args,
+ ("-m", "-hfr"),
+ model,
+ "--model",
+ )
+ legacy_frontend, extra_llama_args = _consume_legacy_short_aliases(
+ extra_llama_args,
+ ("-f",),
+ str(frontend) if frontend is not None else None,
+ "--frontend",
+ )
+ if legacy_frontend is not None and frontend is None:
+ frontend = Path(legacy_frontend)
+
+ if model is None:
+ typer.echo(
+ "Error: Missing option '--model' / '-hf' / '--hf-repo' "
+ "(legacy aliases '-m' / '-hfr' are still accepted).",
+ err = True,
+ )
+ raise typer.Exit(2)
+
+ # 0. Parse llama.cpp `repo:variant` in --model; error if also paired
+ # with --gguf-variant and they disagree.
parsed_repo, embedded_variant = _split_repo_variant(model)
if embedded_variant:
if gguf_variant and gguf_variant != embedded_variant:
@@ -719,8 +939,8 @@ def run(
model = parsed_repo
gguf_variant = gguf_variant or embedded_variant
- # ── Resolve the server-side tool policy. The y/N prompt (if any)
- # runs in the outer process so the re-exec'd child never re-prompts.
+ # Resolve tool policy here so the re-exec'd child inherits a
+ # concrete decision and never re-prompts.
from unsloth_cli._tool_policy import is_external_host, resolve_tool_policy
enable_tools = resolve_tool_policy(
@@ -730,7 +950,7 @@ def run(
silent = silent,
)
- # ── 1. Venv re-exec (same pattern as studio_default) ──────────────
+ # 1. Re-exec into the studio venv (same pattern as studio_default).
studio_venv_dir = STUDIO_HOME / "unsloth_studio"
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
@@ -739,7 +959,7 @@ def run(
if not studio_python:
typer.echo("Studio not set up. Run install.sh first.")
raise typer.Exit(1)
- # Re-exec into the studio venv via its `unsloth` entry point
+ # Re-exec via the studio venv's `unsloth` console-script.
studio_bin = studio_python.parent / "unsloth"
if not studio_bin.is_file():
typer.echo(
@@ -763,27 +983,26 @@ def run(
]
if gguf_variant:
args.extend(["--gguf-variant", gguf_variant])
- if not load_in_4bit:
- args.append("--no-load-in-4bit")
+ # Forward the explicit polarity; a future default flip on one
+ # layer must not silently invert behaviour for the other.
+ args.append("--load-in-4bit" if load_in_4bit else "--no-load-in-4bit")
if frontend:
args.extend(["--frontend", str(frontend)])
if silent:
args.append("--silent")
- # Forward the resolved tool policy (always concrete True/False
- # at this point — the resolver above ran before the re-exec).
+ # Forward the resolved tool policy so the child doesn't re-resolve.
if enable_tools:
args.append("--enable-tools")
else:
args.append("--disable-tools")
- # Forward --yes whenever the parent already cleared the prompt
- # (either operator passed --yes, or the parent's resolver
- # accepted the network-bind confirmation). Otherwise the child
- # re-runs the resolver and prompts a second time.
+ # Forward --yes if the parent already cleared the network-bind
+ # prompt, else the child re-prompts.
if yes or (enable_tools and is_external_host(host)):
args.append("--yes")
- # Forward unknown args (llama-server pass-through) to the
- # re-exec'd command so the studio venv sees them in ctx.args
- # and the re-execed run() can include them in the load payload.
+ # Typer claims --parallel outside ctx.args; without this the
+ # child reverts to its default and silently drops the value.
+ args.extend(["--parallel", str(parallel)])
+ # llama-server pass-through extras → child ctx.args → load payload.
if extra_llama_args:
args.extend(extra_llama_args)
@@ -800,34 +1019,31 @@ def run(
# ── 2. Start server (always suppress built-in banner) ─────────────
from studio.backend.run import run_server, _resolve_external_ip
- run_kwargs = dict(host = host, port = port, silent = True, llama_parallel_slots = 4)
+ run_kwargs = dict(host = host, port = port, silent = True, llama_parallel_slots = parallel)
if frontend is not None:
run_kwargs["frontend_path"] = frontend
app = run_server(**run_kwargs)
actual_port = getattr(app.state, "server_port", port) or port
- # ── Apply the resolved tool policy as a process-level override.
- # Must use the same import path the route handlers use --
- # `studio/backend/run.py` adds `studio/backend/` to sys.path so the
- # routes import this module as top-level `state.tool_policy`. If we
- # imported via `studio.backend.state.tool_policy` instead, Python
- # would cache two different module objects with two different
- # `_tool_policy` globals, and the gates would never see our value.
+ # Match the route handlers' import path: run.py adds
+ # studio/backend/ to sys.path, so they import as `state.tool_policy`.
+ # Importing via `studio.backend.state.tool_policy` would cache a
+ # second module object whose flag the gates can't see.
from state.tool_policy import set_tool_policy
set_tool_policy(enable_tools)
- # ── 3. Wait for server health ─────────────────────────────────────
+ # 3. Wait for server health.
if not silent:
typer.echo("Starting Unsloth Studio...")
if not _wait_for_server(actual_port):
typer.echo("Error: server did not become healthy within 30 seconds.", err = True)
raise typer.Exit(1)
- # ── 4. Create API key in-process ──────────────────────────────────
+ # 4. Create API key in-process.
api_key = _create_api_key_inprocess(api_key_name)
- # ── 5. Load model via HTTP ────────────────────────────────────────
+ # 5. Load model via HTTP.
if not silent:
typer.echo(f"Loading model: {model}...")
try:
@@ -847,15 +1063,13 @@ def run(
loaded_model = result.get("model", model)
display_variant = f" ({gguf_variant})" if gguf_variant else ""
- # ── 6. Print banner ───────────────────────────────────────────────
+ # 6. Print banner.
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
base_url = f"http://{display_host}:{actual_port}"
sdk_base_url = f"{base_url}/v1"
- # Claude orange (Claude Code's brand color) for tool-policy notices
- # so they stand out from the surrounding banner. Always printed --
- # even under --silent / --yes -- so the operator never misses the
- # current tool-execution status.
+ # Orange so the tool-policy notice stands out; printed under
+ # --silent / --yes too so the policy is never invisible.
_tool_notice_fg = (217, 119, 87)
_is_external = is_external_host(host)
if _is_external and enable_tools:
@@ -913,14 +1127,12 @@ def run(
typer.echo(""" -d '{"input": "Hello", "stream": true}'""")
typer.echo("")
else:
- # Silent mode still prints the essentials (URL, API key) plus
- # the orange tool-status notice so the operator never loses
- # visibility into the security-relevant policy.
+ # Silent still prints URL + API key + tool-status policy.
typer.echo(f"URL: {base_url}")
typer.echo(f"API Key: {api_key}")
typer.secho(_tool_notice, fg = _tool_notice_fg, bold = True)
- # ── 7. Wait for Ctrl+C ────────────────────────────────────────────
+ # 7. Wait for Ctrl+C.
from studio.backend.run import _shutdown_event, _graceful_shutdown, _server
try:
diff --git a/unsloth_cli/tests/test_studio_run_parallel_flag.py b/unsloth_cli/tests/test_studio_run_parallel_flag.py
new file mode 100644
index 0000000000..561600e43b
--- /dev/null
+++ b/unsloth_cli/tests/test_studio_run_parallel_flag.py
@@ -0,0 +1,416 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Tests for the `unsloth studio run --parallel` CLI flag.
+
+Pre-PR `llama_parallel_slots` was hardcoded to 4. These tests pin
+the typer Option (aliases, default 4, 1..64 range), the
+typer/denylist subset invariant, and re-exec forwarding.
+
+See ``test_studio_run_short_alias_clashes.py`` for the argv
+canonicaliser and the legacy `-m` / `-hfr` / `-f` shim.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+from typer.testing import CliRunner
+
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+
+def _load_run_command():
+ """Import `studio` without triggering server start; backend imports
+ are lazy inside run()."""
+ from unsloth_cli.commands import studio as _studio
+
+ return _studio
+
+
+def test_parallel_option_is_registered():
+ """The `--parallel` flag (with aliases) must be on the `run` command."""
+ studio_mod = _load_run_command()
+ import inspect
+
+ run_fn = studio_mod.run
+ sig = inspect.signature(run_fn)
+ assert "parallel" in sig.parameters, "missing `parallel` parameter on run()"
+
+ param = sig.parameters["parallel"]
+ opt = param.default # typer.OptionInfo
+ flags = set()
+ decls = getattr(opt, "param_decls", None) or []
+ for d in decls:
+ flags.add(d)
+ for required in ("--parallel", "--n-parallel", "-np"):
+ assert required in flags, f"flag {required!r} missing from --parallel option"
+
+
+def test_parallel_default_is_four():
+ """Default must stay at 4 so plain `unsloth studio run` is unchanged."""
+ studio_mod = _load_run_command()
+ import inspect
+
+ sig = inspect.signature(studio_mod.run)
+ opt = sig.parameters["parallel"].default
+ default = getattr(opt, "default", None)
+ assert (
+ default == 4
+ ), f"default changed to {default}; would silently alter existing deployments"
+
+
+def test_parallel_range_guards_are_set():
+ """Range guards: 1 <= N <= 64. Outside this is a hard reject."""
+ studio_mod = _load_run_command()
+ import inspect
+
+ sig = inspect.signature(studio_mod.run)
+ opt = sig.parameters["parallel"].default
+ assert getattr(opt, "min", None) == 1, "min must be 1 (0 = no decode possible)"
+ assert getattr(opt, "max", None) == 64, "max must be 64 (KV split sanity cap)"
+
+
+def test_typer_parallel_aliases_are_subset_of_backend_denylist():
+ """Every typer alias for --parallel must be denied on the backend
+ too; otherwise HTTP /load could smuggle the value via
+ `llama_extra_args` and desync llama_parallel_slots from the
+ running llama-server."""
+ studio_mod = _load_run_command()
+ import inspect
+ import importlib.util
+
+ # Load llama_server_args.py directly so the test doesn't need the
+ # backend's full runtime chain (fastapi / structlog / loggers /
+ # utils.hardware) installed -- the invariant is just about the
+ # _DENYLIST_GROUPS tuple.
+ lsa_path = (
+ Path(__file__).resolve().parents[2]
+ / "studio"
+ / "backend"
+ / "core"
+ / "inference"
+ / "llama_server_args.py"
+ )
+ spec = importlib.util.spec_from_file_location("_lsa_for_subset_test", lsa_path)
+ lsa = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(lsa)
+ _DENYLIST_GROUPS = lsa._DENYLIST_GROUPS
+
+ parallel_group = next((g for g in _DENYLIST_GROUPS if "--parallel" in g), None)
+ assert parallel_group is not None, "denylist must include a --parallel group"
+
+ sig = inspect.signature(studio_mod.run)
+ opt = sig.parameters["parallel"].default
+ typer_aliases = set(getattr(opt, "param_decls", []) or [])
+ missing = typer_aliases - parallel_group
+ assert not missing, (
+ f"typer aliases {missing!r} are not in the backend denylist; "
+ f"add them to _DENYLIST_GROUPS to keep /load from desyncing "
+ f"llama_parallel_slots."
+ )
+
+
+# test_in_venv_path_passes_parallel_to_run_server (below) is the runtime
+# equivalent of the retired source-text guard for hardcoded
+# `llama_parallel_slots = 4`.
+
+
+# Re-exec arg-builder coverage. run() re-execs into the studio venv
+# (execvp on POSIX, Popen on Windows). Without explicit forwarding the
+# child reverts to typer defaults and silently drops the user's value.
+
+
+class _ExecCaptured(SystemExit):
+ def __init__(self, argv):
+ super().__init__(0)
+ self.argv = list(argv)
+
+
+def _install_reexec_capture(monkeypatch, *, platform):
+ studio_mod = _load_run_command()
+ captured = []
+
+ monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
+
+ fake_venv = Path("/fake/studio/venv/unsloth_studio")
+ fake_python = fake_venv / "bin" / "python"
+ fake_bin = fake_venv / "bin" / "unsloth"
+ monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_python)
+
+ real_is_file = Path.is_file
+ monkeypatch.setattr(
+ Path,
+ "is_file",
+ lambda self: True if str(self) == str(fake_bin) else real_is_file(self),
+ )
+
+ # resolve_tool_policy is imported lazily inside run(); patch the source.
+ from unsloth_cli import _tool_policy as _tp_mod
+
+ monkeypatch.setattr(
+ _tp_mod,
+ "resolve_tool_policy",
+ lambda host, flag, yes, silent: False if flag is None else bool(flag),
+ )
+
+ monkeypatch.setattr(sys, "platform", platform)
+
+ def fake_execvp(file, argv):
+ captured.append({"kind": "execvp", "argv": list(argv)})
+ raise _ExecCaptured(argv)
+
+ class _FakePopen:
+ def __init__(self, argv, *a, **kw):
+ captured.append({"kind": "popen", "argv": list(argv)})
+ self._argv = argv
+
+ def wait(self):
+ raise _ExecCaptured(self._argv)
+
+ monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
+ monkeypatch.setattr(studio_mod.subprocess, "Popen", _FakePopen)
+
+ return captured
+
+
+def _invoke_run(monkeypatch, args, *, platform = "linux"):
+ import typer as _typer
+
+ studio_mod = _load_run_command()
+ captured = _install_reexec_capture(monkeypatch, platform = platform)
+ app = _typer.Typer()
+ app.command(
+ context_settings = {
+ "allow_extra_args": True,
+ "ignore_unknown_options": True,
+ },
+ )(studio_mod.run)
+ result = CliRunner().invoke(app, args, catch_exceptions = True)
+ return result, captured
+
+
+def _value_after(argv, flag):
+ for i, tok in enumerate(argv):
+ if tok == flag and i + 1 < len(argv):
+ return argv[i + 1]
+ return None
+
+
+_BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"]
+
+
+@pytest.mark.parametrize(
+ "flag,value",
+ [("--parallel", "8"), ("--n-parallel", "16"), ("-np", "32")],
+)
+def test_reexec_forwards_parallel_all_aliases(monkeypatch, flag, value):
+ """Every alias the user can type must reach the re-exec'd child."""
+ result, captured = _invoke_run(monkeypatch, _BASE + [flag, value])
+ assert (
+ len(captured) == 1
+ ), f"expected one launch via re-exec, got {captured}; output={result.output!r}"
+ argv = captured[0]["argv"]
+ assert (
+ _value_after(argv, "--parallel") == value
+ ), f"{flag} {value} was dropped on re-exec; argv = {argv}"
+
+
+@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"])
+def test_reexec_argv_is_consistent_across_platforms(monkeypatch, platform):
+ """Linux/Darwin (execvp) and Windows (Popen) must build the same argv."""
+ result, captured = _invoke_run(
+ monkeypatch, _BASE + ["--parallel", "12"], platform = platform
+ )
+ assert len(captured) == 1
+ expected_kind = "popen" if platform == "win32" else "execvp"
+ assert (
+ captured[0]["kind"] == expected_kind
+ ), f"{platform}: expected launcher {expected_kind}, got {captured[0]['kind']}"
+ assert _value_after(captured[0]["argv"], "--parallel") == "12"
+
+
+def test_reexec_np_is_first_class_alias(monkeypatch):
+ """`-np` must reach the child as --parallel . Pre-PR Click
+ clustered `-np 8` as `-p 8` (port=8) + stray `-n`; also pin that
+ --port is no longer collateral damage."""
+ result, captured = _invoke_run(monkeypatch, _BASE + ["-np", "8"])
+ assert len(captured) == 1
+ argv = captured[0]["argv"]
+ assert (
+ _value_after(argv, "--parallel") == "8"
+ ), f"-np 8 silently became 4 after re-exec; argv = {argv}"
+ # `-np 8` must not clobber --port (default 8888).
+ assert _value_after(argv, "--port") == "8888", argv
+
+
+def test_reexec_mixed_parallel_with_passthrough(monkeypatch):
+ """--parallel + llama-server pass-through flags must all reach the child."""
+ result, captured = _invoke_run(
+ monkeypatch,
+ _BASE + ["--parallel", "8", "--top-k", "20", "--temp", "0.7"],
+ )
+ assert len(captured) == 1
+ argv = captured[0]["argv"]
+ assert _value_after(argv, "--parallel") == "8", argv
+ assert _value_after(argv, "--top-k") == "20", argv
+ assert _value_after(argv, "--temp") == "0.7", argv
+
+
+@pytest.mark.parametrize(
+ "user_flag,expected_in_child",
+ [
+ ("--load-in-4bit", "--load-in-4bit"),
+ ("--no-load-in-4bit", "--no-load-in-4bit"),
+ (None, "--load-in-4bit"), # default True
+ ],
+)
+def test_reexec_forwards_load_in_4bit_in_both_directions(
+ monkeypatch, user_flag, expected_in_child
+):
+ """Re-exec must emit the chosen polarity (or the typer default),
+ so a future default flip on one layer can't silently invert
+ behaviour for users who never typed the flag."""
+ extras = [user_flag] if user_flag else []
+ result, captured = _invoke_run(monkeypatch, _BASE + extras)
+ assert len(captured) == 1
+ argv = captured[0]["argv"]
+ other_polarity = (
+ "--no-load-in-4bit"
+ if expected_in_child == "--load-in-4bit"
+ else "--load-in-4bit"
+ )
+ assert (
+ expected_in_child in argv
+ ), f"expected {expected_in_child} in child argv; got {argv}"
+ assert (
+ other_polarity not in argv
+ ), f"unexpected {other_polarity} in child argv; got {argv}"
+
+
+# Runtime check: fake sys.prefix into the studio venv to bypass
+# re-exec, then assert run_server receives --parallel as
+# llama_parallel_slots.
+
+
+class _RunServerCaptured(SystemExit):
+ def __init__(self, kwargs):
+ super().__init__(0)
+ self.kwargs = dict(kwargs)
+
+
+def _types_module(name):
+ import types as _types
+
+ return _types.ModuleType(name)
+
+
+def test_studio_default_rejects_parallel_when_subcommand_invoked():
+ """`unsloth studio --parallel 8 run ...` would silently drop the 8
+ (typer doesn't forward parent options to subcommands). The
+ callback rejects with exit 2 and points at the subcommand flag."""
+ studio_mod = _load_run_command()
+ import typer as _typer
+
+ app = _typer.Typer()
+ app.add_typer(studio_mod.studio_app, name = "studio")
+
+ runner = CliRunner()
+ result = runner.invoke(app, ["studio", "--parallel", "8", "run", "--model", "X"])
+ assert result.exit_code == 2, (
+ f"expected exit 2 when --parallel is on studio group with a "
+ f"subcommand invoked; got {result.exit_code}; output={result.output!r}"
+ )
+ combined = (result.output or "") + (getattr(result, "stderr", "") or "")
+ assert "--parallel" in combined, combined
+ assert (
+ "run --parallel 8" in combined
+ ), f"error message must show the corrected invocation; got: {combined}"
+
+
+def test_studio_default_default_parallel_with_subcommand_does_not_error():
+ """Omitting --parallel on the group must still let subcommands
+ run; the group's default 1 is benign."""
+ studio_mod = _load_run_command()
+ import typer as _typer
+
+ app = _typer.Typer()
+ app.add_typer(studio_mod.studio_app, name = "studio")
+ runner = CliRunner()
+ result = runner.invoke(app, ["studio", "--help"])
+ assert result.exit_code == 0, result.output
+
+
+def test_studio_default_exposes_parallel_option():
+ """Plain `unsloth studio` exposes --parallel too so the API-only
+ path can raise concurrency without going through the denied
+ pass-through. Default stays at 1 (pre-PR); `run` keeps its 4."""
+ studio_mod = _load_run_command()
+ import inspect
+
+ sig = inspect.signature(studio_mod.studio_default)
+ assert "parallel" in sig.parameters, (
+ "studio_default missing `parallel`; API-only path can't set "
+ "llama_parallel_slots"
+ )
+ opt = sig.parameters["parallel"].default
+ decls = set(getattr(opt, "param_decls", []) or [])
+ assert "--parallel" in decls
+ assert "--n-parallel" in decls
+ assert (
+ getattr(opt, "default", None) == 1
+ ), "studio_default --parallel must default to 1 (pre-PR); `run` is 4"
+ assert getattr(opt, "min", None) == 1
+ assert getattr(opt, "max", None) == 64
+
+
+@pytest.mark.parametrize("value", [1, 4, 8, 64])
+def test_in_venv_path_passes_parallel_to_run_server(monkeypatch, value):
+ """In-venv path must forward --parallel to
+ run_server(llama_parallel_slots=N), not the old hardcoded 4."""
+ studio_mod = _load_run_command()
+
+ fake_venv = Path("/fake/studio/venv/unsloth_studio")
+ monkeypatch.setattr(sys, "prefix", str(fake_venv))
+ # Pin STUDIO_HOME so sys.prefix.startswith() picks the in-venv branch.
+ monkeypatch.setattr(studio_mod, "STUDIO_HOME", fake_venv.parent)
+
+ from unsloth_cli import _tool_policy as _tp_mod
+
+ monkeypatch.setattr(
+ _tp_mod,
+ "resolve_tool_policy",
+ lambda host, flag, yes, silent: False if flag is None else bool(flag),
+ )
+
+ captured: dict = {}
+
+ def fake_run_server(**kwargs):
+ captured.update(kwargs)
+ raise _RunServerCaptured(kwargs)
+
+ fake_backend_run = sys.modules.setdefault(
+ "studio.backend.run", _types_module("studio.backend.run")
+ )
+ fake_backend_run.run_server = fake_run_server
+ fake_backend_run._resolve_external_ip = lambda: "127.0.0.1"
+
+ import typer as _typer
+
+ app = _typer.Typer()
+ app.command(
+ context_settings = {
+ "allow_extra_args": True,
+ "ignore_unknown_options": True,
+ },
+ )(studio_mod.run)
+ CliRunner().invoke(app, _BASE + ["--parallel", str(value)], catch_exceptions = True)
+
+ assert (
+ captured.get("llama_parallel_slots") == value
+ ), f"run_server got llama_parallel_slots={captured.get('llama_parallel_slots')!r}, expected {value}"
diff --git a/unsloth_cli/tests/test_studio_run_short_alias_clashes.py b/unsloth_cli/tests/test_studio_run_short_alias_clashes.py
new file mode 100644
index 0000000000..8a3e94db47
--- /dev/null
+++ b/unsloth_cli/tests/test_studio_run_short_alias_clashes.py
@@ -0,0 +1,550 @@
+# 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 short-alias clashes with llama-server flags.
+
+`unsloth studio run` passes unknown flags through to llama-server.
+Pre-cleanup it exposed 1-char shorts ``-m`` / ``-f`` plus ``-hfr``;
+Click clustered llama-server tokens against them (``-fa`` -> ``-f a``,
+``-mg 0`` -> ``-m g``, ``-fitt 1024`` -> ``-f itt``, ...), silently
+breaking ~11 pass-through flags.
+
+The cleanup drops ``-m``, ``-f``, ``-hfr``. The 2-char ``-hf`` stays
+(documented; multi-char shorts don't cluster). Long forms remain.
+``studio_default`` keeps ``-f`` because it has no pass-through.
+
+See ``test_studio_run_parallel_flag.py`` for ``--parallel`` /
+``-np`` coverage and re-exec forwarding.
+"""
+
+from __future__ import annotations
+
+import inspect
+import sys
+from pathlib import Path
+
+import pytest
+from typer.testing import CliRunner
+
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+
+def _studio_mod():
+ from unsloth_cli.commands import studio as _s
+
+ return _s
+
+
+def _decls_for(param_name):
+ sig = inspect.signature(_studio_mod().run)
+ opt = sig.parameters[param_name].default
+ return set(getattr(opt, "param_decls", []) or [])
+
+
+# Surface checks: removed shorts must not reappear.
+
+
+def test_model_short_aliases_removed():
+ """`-m` / `-hfr` removed from --model; `-hf` kept (multi-char,
+ doesn't cluster)."""
+ decls = _decls_for("model")
+ assert "-m" not in decls, "`-m` re-added; brings back `-mg`/`-md` clustering"
+ assert "-hfr" not in decls, "`-hfr` was re-added; remove it"
+ assert "--model" in decls
+ assert "--hf-repo" in decls
+ assert "-hf" in decls, "`-hf` is documented and must keep working"
+
+
+def test_frontend_short_alias_removed_from_run():
+ """`-f` must not be on `run` (eats `-fa`/`-fit`/`-fitt`/`-fitc`)."""
+ decls = _decls_for("frontend")
+ assert "-f" not in decls, "`-f` re-added on run(); brings back `-fa` clustering"
+ assert "--frontend" in decls
+
+
+def test_studio_default_keeps_dash_f():
+ """`studio_default` keeps `-f`: no pass-through tail to clash with."""
+ sig = inspect.signature(_studio_mod().studio_default)
+ opt = sig.parameters["frontend"].default
+ decls = set(getattr(opt, "param_decls", []) or [])
+ assert "-f" in decls
+
+
+# Behaviour checks: llama-server shorts must reach the child verbatim.
+
+
+class _ExecCaptured(SystemExit):
+ def __init__(self, argv):
+ super().__init__(0)
+ self.argv = list(argv)
+
+
+def _install_capture(monkeypatch):
+ studio_mod = _studio_mod()
+ captured = []
+ monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
+ fake_bin = Path("/fake/studio/venv/unsloth_studio/bin/unsloth")
+ monkeypatch.setattr(
+ studio_mod, "_studio_venv_python", lambda: fake_bin.parent / "python"
+ )
+ real_is_file = Path.is_file
+ monkeypatch.setattr(
+ Path,
+ "is_file",
+ lambda self: True if str(self) == str(fake_bin) else real_is_file(self),
+ )
+ from unsloth_cli import _tool_policy as _tp
+
+ monkeypatch.setattr(
+ _tp,
+ "resolve_tool_policy",
+ lambda host, flag, yes, silent: False if flag is None else bool(flag),
+ )
+ monkeypatch.setattr(sys, "platform", "linux")
+
+ def fake_execvp(file, argv):
+ captured.append(list(argv))
+ raise _ExecCaptured(argv)
+
+ monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
+ return captured
+
+
+def _invoke(monkeypatch, args):
+ import typer as _typer
+
+ studio_mod = _studio_mod()
+ captured = _install_capture(monkeypatch)
+ app = _typer.Typer()
+ app.command(
+ context_settings = {
+ "allow_extra_args": True,
+ "ignore_unknown_options": True,
+ },
+ )(studio_mod.run)
+ CliRunner().invoke(app, args, catch_exceptions = True)
+ return captured
+
+
+# (short_flag, value, llama-server long name). All were silently
+# mis-parsed pre-cleanup.
+_PREVIOUSLY_BROKEN = [
+ ("-fa", None, "--flash-attn"),
+ ("-fit", None, "--fit"),
+ ("-fitt", "1024", "--fit-target"),
+ ("-fitc", "4096", "--fit-ctx"),
+ ("-mg", "0", "--main-gpu"),
+ ("-md", "/path/draft.gguf", "--spec-draft-model"),
+ ("-hff", "Q4_K_M.gguf", "--hf-file"),
+ ("-cmoe", None, "--cpu-moe"),
+ ("-cram", "16384", "--cache-ram"),
+ ("-sm", "row", "--split-mode"),
+ ("-ncmoe", "8", "--n-cpu-moe"),
+]
+
+
+@pytest.mark.parametrize("flag,value,llama_long_name", _PREVIOUSLY_BROKEN)
+def test_previously_broken_short_flag_now_passes_through(
+ monkeypatch,
+ flag,
+ value,
+ llama_long_name,
+):
+ """Each of these was eaten by typer pre-cleanup; must pass through verbatim now."""
+ extras = [flag] if value is None else [flag, value]
+ captured = _invoke(monkeypatch, ["--model", "X"] + extras)
+ assert len(captured) == 1, f"parent did not re-exec for {extras}"
+ argv = captured[0]
+ assert flag in argv, (
+ f"llama-server short flag {flag!r} ({llama_long_name}) was eaten "
+ f"by typer; child argv = {argv}"
+ )
+ if value is not None:
+ idx = argv.index(flag)
+ assert (
+ idx + 1 < len(argv) and argv[idx + 1] == value
+ ), f"value for {flag!r} was lost or moved; argv = {argv}"
+
+
+def test_dash_hf_documented_alias_still_works(monkeypatch):
+ """`-hf` is documented and must keep working (multi-char shorts
+ don't cluster in Click)."""
+ captured = _invoke(
+ monkeypatch,
+ ["-hf", "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_XL"],
+ )
+ assert len(captured) == 1
+ argv = captured[0]
+ # `_split_repo_variant` peels the `:variant` suffix before re-exec.
+ assert argv[argv.index("--model") + 1] == ("unsloth/gemma-4-26B-A4B-it-GGUF"), argv
+ assert argv[argv.index("--gguf-variant") + 1] == "UD-Q4_K_XL", argv
+
+
+# Legacy `-m` / `-hfr` / `-f` were typer aliases pre-PR. The
+# preprocessor promotes EXACT matches back to their typer params and
+# leaves clustered tokens (`-mg`, `-fa`, ...) in the pass-through tail.
+
+
+@pytest.mark.parametrize(
+ "legacy_args,expected_model",
+ [
+ (["-m", "unsloth/Qwen3-1.7B-GGUF"], "unsloth/Qwen3-1.7B-GGUF"),
+ (["-m=unsloth/Qwen3-1.7B-GGUF"], "unsloth/Qwen3-1.7B-GGUF"),
+ (["-hfr", "unsloth/Qwen3-1.7B-GGUF"], "unsloth/Qwen3-1.7B-GGUF"),
+ (["-hfr=unsloth/Qwen3-1.7B-GGUF"], "unsloth/Qwen3-1.7B-GGUF"),
+ ],
+)
+def test_legacy_model_aliases_still_promote_to_model(
+ monkeypatch,
+ legacy_args,
+ expected_model,
+):
+ """Pre-PR `-m X` / `-hfr X` set --model X; preprocessor preserves that."""
+ captured = _invoke(monkeypatch, legacy_args)
+ assert len(captured) == 1, f"parent did not re-exec for {legacy_args}"
+ argv = captured[0]
+ assert argv[argv.index("--model") + 1] == expected_model, argv
+ # Promoted alias must not also leak into the pass-through tail.
+ for alias in ("-m", "-hfr"):
+ if alias in legacy_args:
+ assert alias not in argv, f"legacy {alias} leaked into child argv: {argv}"
+
+
+def test_legacy_frontend_alias_still_promotes_to_frontend(monkeypatch):
+ """Pre-PR `-f dist` set --frontend dist; preprocessor preserves it."""
+ captured = _invoke(monkeypatch, ["--model", "X", "-f", "/tmp/dist"])
+ assert len(captured) == 1
+ argv = captured[0]
+ # Compare via Path so Windows's str(Path("/tmp/dist")) = "\tmp\dist"
+ # doesn't trip the assertion on the same logical path.
+ assert Path(argv[argv.index("--frontend") + 1]) == Path("/tmp/dist"), argv
+ assert "-f" not in argv, f"-f leaked into child argv: {argv}"
+
+
+def test_legacy_model_alias_conflicts_with_long_form(monkeypatch):
+ """`--model X` plus `-m Y` is ambiguous; must error pre-re-exec."""
+ captured = _invoke(monkeypatch, ["--model", "X", "-m", "Y"])
+ assert (
+ len(captured) == 0
+ ), f"expected error before re-exec, got launch with argv = {captured}"
+
+
+def test_clustered_tokens_are_not_promoted(monkeypatch):
+ """`-mg` / `-fa` / `-fitt` are llama-server flags and must survive
+ in the tail even though they start with `-m` / `-f`."""
+ captured = _invoke(
+ monkeypatch,
+ ["--model", "X", "-mg", "0", "-fa", "-fitt", "1024"],
+ )
+ assert len(captured) == 1
+ argv = captured[0]
+ assert argv[argv.index("--model") + 1] == "X", argv
+ for flag in ("-mg", "-fa", "-fitt"):
+ assert flag in argv, f"{flag!r} was promoted instead of passed through: {argv}"
+
+
+def test_legacy_m_with_repo_variant_syntax(monkeypatch):
+ """`-m repo:variant` must round-trip through preprocessor +
+ _split_repo_variant into --model + --gguf-variant."""
+ captured = _invoke(
+ monkeypatch,
+ ["-m", "unsloth/Qwen3-1.7B-GGUF:UD-Q4_K_XL"],
+ )
+ assert len(captured) == 1
+ argv = captured[0]
+ assert argv[argv.index("--model") + 1] == "unsloth/Qwen3-1.7B-GGUF", argv
+ assert argv[argv.index("--gguf-variant") + 1] == "UD-Q4_K_XL", argv
+
+
+def test_missing_model_after_preprocessor_errors(monkeypatch):
+ """Neither --model nor a legacy alias → clean exit(2) before re-exec."""
+ captured = _invoke(monkeypatch, ["--parallel", "8"])
+ assert (
+ len(captured) == 0
+ ), f"expected exit before re-exec, got launch with argv = {captured}"
+
+
+def test_legacy_m_inline_value_form(monkeypatch):
+ """`-m=foo` is promoted like `-m foo`."""
+ captured = _invoke(monkeypatch, ["-m=unsloth/Qwen3-1.7B-GGUF"])
+ assert len(captured) == 1
+ argv = captured[0]
+ assert argv[argv.index("--model") + 1] == "unsloth/Qwen3-1.7B-GGUF", argv
+
+
+# Unit tests for _consume_legacy_short_aliases.
+
+
+def test_consume_helper_exact_match_space_form():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(
+ ["-m", "FOO", "--top-k", "20"],
+ ("-m",),
+ None,
+ "--model",
+ )
+ assert value == "FOO"
+ assert remaining == ["--top-k", "20"]
+
+
+def test_consume_helper_exact_match_inline_form():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(
+ ["-m=FOO", "--top-k", "20"],
+ ("-m",),
+ None,
+ "--model",
+ )
+ assert value == "FOO"
+ assert remaining == ["--top-k", "20"]
+
+
+def test_consume_helper_leaves_clusters_alone():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(
+ ["-mg", "0", "-md", "/x"],
+ ("-m",),
+ None,
+ "--model",
+ )
+ assert value is None
+ assert remaining == ["-mg", "0", "-md", "/x"]
+
+
+def test_consume_helper_value_already_set_raises():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ import typer as _typer
+
+ with pytest.raises(_typer.BadParameter):
+ helper(["-m", "Y"], ("-m",), "X", "--model")
+
+
+def test_consume_helper_missing_value_raises():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ import typer as _typer
+
+ with pytest.raises(_typer.BadParameter):
+ helper(["-m"], ("-m",), None, "--model")
+
+
+def test_consume_helper_multiple_aliases_in_group():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(
+ ["-hfr", "FOO", "--top-k", "20"],
+ ("-m", "-hfr"),
+ None,
+ "--model",
+ )
+ assert value == "FOO"
+ assert remaining == ["--top-k", "20"]
+
+
+def test_consume_helper_preserves_value_when_no_match():
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(
+ ["--top-k", "20"],
+ ("-m",),
+ "PRESET",
+ "--model",
+ )
+ assert value == "PRESET"
+ assert remaining == ["--top-k", "20"]
+
+
+# `-p` is typer short for --port, so Click clusters `-np8` as `-n -p 8`
+# (port=8, parallel dropped). The rewrite splits to `-np 8` pre-parse.
+
+
+def test_expand_np_rewrites_attached_form(monkeypatch):
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["unsloth", "studio", "run", "--model", "X", "-np8"],
+ )
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == [
+ "unsloth",
+ "studio",
+ "run",
+ "--model",
+ "X",
+ "-np",
+ "8",
+ ]
+
+
+@pytest.mark.parametrize("value", ["1", "8", "64", "999"])
+def test_expand_np_rewrites_all_digit_values(monkeypatch, value):
+ monkeypatch.setattr(sys, "argv", ["unsloth", "studio", "run", f"-np{value}"])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "studio", "run", "-np", value]
+
+
+def test_expand_np_leaves_space_form_alone(monkeypatch):
+ monkeypatch.setattr(sys, "argv", ["unsloth", "run", "-np", "8"])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-np", "8"]
+
+
+def test_expand_np_leaves_equals_form_alone(monkeypatch):
+ monkeypatch.setattr(sys, "argv", ["unsloth", "run", "-np=8"])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-np=8"]
+
+
+def test_expand_np_leaves_non_digit_suffix_alone(monkeypatch):
+ # `-npfoo` isn't a numeric attached value; let typer reject it.
+ monkeypatch.setattr(sys, "argv", ["unsloth", "run", "-npfoo"])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-npfoo"]
+
+
+def test_expand_np_leaves_bare_np_alone(monkeypatch):
+ monkeypatch.setattr(sys, "argv", ["unsloth", "run", "-np"])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-np"]
+
+
+def test_expand_np_handles_multiple_occurrences(monkeypatch):
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["unsloth", "run", "-np8", "-np16"],
+ )
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-np", "8", "-np", "16"]
+
+
+@pytest.mark.parametrize("attached,expected", [("-np-1", "-1"), ("-np+1", "+1")])
+def test_expand_np_handles_signed_attached_forms(monkeypatch, attached, expected):
+ """Signed `-np-1` / `-np+1` must split too, else Click sets port=-1."""
+ monkeypatch.setattr(sys, "argv", ["unsloth", "run", attached])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-np", expected]
+
+
+@pytest.mark.parametrize(
+ "attached,expected_suffix",
+ [("-np8x", "8x"), ("-np-1foo", "-1foo"), ("-np9bar", "9bar")],
+)
+def test_expand_np_rewrites_numeric_prefix_even_with_junk(
+ monkeypatch, attached, expected_suffix
+):
+ """`-np8x` would surface as a baffling --port error; rewriting to
+ `-np 8x` makes typer report against `-np` where it was typed."""
+ monkeypatch.setattr(sys, "argv", ["unsloth", "run", attached])
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "-np", expected_suffix]
+
+
+def test_consume_helper_rejects_empty_inline_value():
+ """`-m=` must error, not silently become --model ''."""
+ import typer as _typer
+
+ helper = _studio_mod()._consume_legacy_short_aliases
+ with pytest.raises(_typer.BadParameter, match = "non-empty"):
+ helper(["-m="], ("-m",), None, "--model")
+
+
+# Gate isolation: importing unsloth_cli from a third-party script must
+# leave its sys.argv intact. Pins the narrow basename set.
+
+
+@pytest.mark.parametrize(
+ "third_party_argv0",
+ [
+ "/home/user/myproj/cli.py",
+ "cli.py",
+ "/usr/bin/some-tool",
+ "pytest",
+ "/opt/wrapper/launch.py",
+ "unsloth-cli",
+ "unsloth-cli.py",
+ ],
+)
+def test_third_party_importers_do_not_trigger_np_rewrite(
+ monkeypatch, third_party_argv0
+):
+ """Only the `unsloth` / `unsloth.exe` console-script may run the
+ canonicaliser; third-party scripts must keep their argv intact."""
+ import os as _os
+ import importlib
+
+ starting_argv = [third_party_argv0, "subcmd", "-np8", "--input", "foo"]
+ monkeypatch.setattr(sys, "argv", list(starting_argv))
+ # Force a fresh import so the import-time gate actually runs.
+ monkeypatch.delitem(sys.modules, "unsloth_cli", raising = False)
+ importlib.import_module("unsloth_cli")
+ assert sys.argv == starting_argv, (
+ f"third-party argv[0]={third_party_argv0!r} triggered the "
+ f"unsloth -np canonicaliser; sys.argv was mutated to {sys.argv}"
+ )
+ _ = _os # silence unused-import linters when monkeypatch lazy-binds
+
+
+def test_attached_np8_no_longer_silently_sets_port(monkeypatch):
+ """After the gate runs, `-np8` produces --parallel=8 (not --port=8)."""
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["unsloth", "studio", "run", "--model", "X", "-np8"],
+ )
+ _studio_mod()._expand_attached_np_short()
+ captured = _invoke(monkeypatch, sys.argv[2:]) # drop "unsloth studio"
+ assert len(captured) == 1, "parent did not re-exec"
+ argv = captured[0]
+ assert argv[argv.index("--parallel") + 1] == "8", argv
+ assert argv[argv.index("--port") + 1] == "8888", argv
+
+
+def test_expand_np_stops_at_double_dash(monkeypatch):
+ """Tokens after `--` are positional; `-np8` stays raw."""
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["unsloth", "run", "--model", "X", "--", "-np8"],
+ )
+ _studio_mod()._expand_attached_np_short()
+ assert sys.argv == ["unsloth", "run", "--model", "X", "--", "-np8"]
+
+
+def test_consume_helper_stops_at_double_dash():
+ """Alias promotion must not reach past `--`."""
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(
+ ["--top-k", "20", "--", "-m", "FOO"],
+ ("-m",),
+ None,
+ "--model",
+ )
+ assert value is None
+ assert remaining == ["--top-k", "20", "--", "-m", "FOO"]
+
+
+def test_consume_helper_rejects_long_flag_as_value():
+ """`-m --flash-attn` errors; `--xxx` is unambiguously a flag."""
+ import typer as _typer
+
+ helper = _studio_mod()._consume_legacy_short_aliases
+ with pytest.raises(_typer.BadParameter, match = "--flash-attn"):
+ helper(["-m", "--flash-attn"], ("-m",), None, "--model")
+
+
+def test_consume_helper_allows_bare_dash_as_value():
+ """Lone `-` is a stdin/path sentinel, not a flag."""
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(["-m", "-", "--top-k", "20"], ("-m",), None, "--model")
+ assert value == "-"
+ assert remaining == ["--top-k", "20"]
+
+
+def test_consume_helper_allows_short_dash_value():
+ """`-foo` may be a path or a leading-dash model name; only `--long`
+ tokens are rejected as values."""
+ helper = _studio_mod()._consume_legacy_short_aliases
+ value, remaining = helper(["-m", "-foo", "--top-k", "20"], ("-m",), None, "--model")
+ assert value == "-foo"
+ assert remaining == ["--top-k", "20"]