{/* Loss chart */}
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 306c02475c..a927f83fd8 100644
--- a/studio/frontend/src/features/training/stores/training-config-store.ts
+++ b/studio/frontend/src/features/training/stores/training-config-store.ts
@@ -3,6 +3,7 @@
import { CPT_TARGET_MODULES, DEFAULT_HYPERPARAMS, LR_DEFAULT_CPT, LR_DEFAULT_FULL, LR_DEFAULT_LORA, STEPS, TARGET_MODULES } from "@/config/training";
import { authFetch } from "@/features/auth";
+import { getHfToken, mirrorHfTokenInto, useHfTokenStore } from "@/features/hub";
import { isAdapterMethod } from "@/types/training";
import type { DatasetFormat } from "@/types/training";
import type { ModelType, StepNumber, TrainingMethod } from "@/types/training";
@@ -117,7 +118,9 @@ let _datasetFormatAutoForcedByCpt = false;
// modelType / isVisionModel / isAudioModel persist so multimodal-only UI
// paints right on reload; the model-config fetch still re-derives them.
+// hfToken mirrors the shared hf-token-store and is persisted there instead.
const NON_PERSISTED_STATE_KEYS: ReadonlySet = new Set([
+ "hfToken",
"isCheckingVision",
"isEmbeddingModel",
"isLoadingModelDefaults",
@@ -632,8 +635,7 @@ export const useTrainingConfigStore = create()(
),
);
},
- setHfToken: (hfToken) =>
- set({ hfToken: hfToken.trim().replace(/^["']+|["']+$/g, "") }),
+ setHfToken: (hfToken) => useHfTokenStore.getState().setToken(hfToken),
setDatasetSource: (datasetSource) => set({ datasetSource }),
selectHfDataset: (dataset) => {
_datasetCheckController?.abort();
@@ -923,7 +925,7 @@ export const useTrainingConfigStore = create()(
_learningRateManuallySet = false;
_yamlLearningRate = undefined;
clearCptDatasetFormatTracking();
- set(initialState);
+ set({ ...initialState, hfToken: getHfToken() });
},
resetToModelDefaults: () => {
const { selectedModel } = get();
@@ -947,7 +949,7 @@ export const useTrainingConfigStore = create()(
},
{
name: "unsloth_training_config_v1",
- version: 11,
+ version: 12,
migrate: (persisted, version) => {
const s = persisted as Record;
if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) {
@@ -1000,6 +1002,15 @@ export const useTrainingConfigStore = create()(
// own version guard.
s.datasetStreaming ??= false;
}
+ if (version < 12) {
+ // hfToken moved to the shared hf-token-store; seed it once so an
+ // existing Studio-only token isn't lost.
+ const legacyToken = typeof s.hfToken === "string" ? s.hfToken.trim() : "";
+ if (legacyToken && !getHfToken()) {
+ useHfTokenStore.getState().setToken(legacyToken);
+ }
+ delete s.hfToken;
+ }
return s as unknown as TrainingConfigStore;
},
partialize: partializePersistedState,
@@ -1022,3 +1033,8 @@ export const useTrainingConfigStore = create()(
},
),
);
+
+const unsubscribeHfTokenMirror = mirrorHfTokenInto(useTrainingConfigStore);
+if (import.meta.hot) {
+ import.meta.hot.dispose(unsubscribeHfTokenMirror);
+}
diff --git a/studio/frontend/src/i18n/locales/ar.ts b/studio/frontend/src/i18n/locales/ar.ts
index 28f404a384..744a2002c9 100644
--- a/studio/frontend/src/i18n/locales/ar.ts
+++ b/studio/frontend/src/i18n/locales/ar.ts
@@ -155,14 +155,14 @@ export const ar = {
"عندما يسمّي طلب متوافق مع OpenAI ملف GGUF مُنزّلاً مختلفًا، يتم تحميله قبل الخدمة. مُعطّل افتراضيًا؛ الأسماء غير المعروفة تُبقي على النموذج المُحمَّل.",
idleUnload: "الإلغاء التلقائي عند الخمول",
idleUnloadDescription:
- "إلغاء تحميل النموذج بعد هذا العدد من ثواني الخمول لتحرير الـ VRAM؛ الطلب التالي يعيد تحميله. القيمة 0 تُبقيه محمَّلاً.",
+ "إلغاء تحميل النموذج بعد هذا العدد من ثواني الخمول لتحرير الـ VRAM؛ الطلب التالي يعيد تحميله. القيمة 0 تُبقيه محمَّلاً. الحد الأدنى 60 ثانية.",
idleNeedsEnable:
"فعّل تبديل النموذج حسب الطلب حتى يعاد تحميل النموذج غير المحمَّل عند الاستخدام التالي.",
idleActiveViaEnv:
"الإلغاء التلقائي عند الخمول مُفعَّل عبر متغير البيئة UNSLOTH_MODEL_IDLE_TTL.",
loadError: "فشل تحميل إعدادات التبديل التلقائي للنموذج.",
saveError: "فشل حفظ إعدادات التبديل التلقائي للنموذج.",
- idleError: "أدخل عددًا صحيحًا من الثواني (0 أو أكثر).",
+ idleError: "أدخل 0 لإبقاء النموذج محمَّلاً، أو 60 ثانية على الأقل.",
},
previewSharing: {
sectionTitle: "مشاركة المعاينة",
diff --git a/studio/frontend/src/i18n/locales/de.ts b/studio/frontend/src/i18n/locales/de.ts
index e38cdbfa0e..7d94e7656e 100644
--- a/studio/frontend/src/i18n/locales/de.ts
+++ b/studio/frontend/src/i18n/locales/de.ts
@@ -158,7 +158,7 @@ export const de = {
"Wenn eine OpenAI-kompatible Anfrage ein anderes heruntergeladenes GGUF nennt, wird dieses vor der Auslieferung geladen. Standardmäßig aus; unbekannte Namen liefern weiterhin das geladene Modell aus.",
idleUnload: "Automatisches Entladen bei Inaktivität",
idleUnloadDescription:
- "Entlädt das Modell nach dieser Anzahl inaktiver Sekunden, um VRAM freizugeben; die nächste Anfrage lädt es erneut. 0 hält es geladen.",
+ "Entlädt das Modell nach dieser Anzahl inaktiver Sekunden, um VRAM freizugeben; die nächste Anfrage lädt es erneut. 0 hält es geladen. Minimum 60 Sekunden.",
idleNeedsEnable:
"Aktivieren Sie \"Modell je Anfrage wechseln\", damit ein entladenes Modell bei der nächsten Nutzung erneut geladen wird.",
idleActiveViaEnv:
@@ -167,7 +167,7 @@ export const de = {
"Einstellungen für automatischen Modellwechsel konnten nicht geladen werden.",
saveError:
"Einstellungen für automatischen Modellwechsel konnten nicht gespeichert werden.",
- idleError: "Geben Sie eine ganze Anzahl an Sekunden ein (0 oder mehr).",
+ idleError: "Geben Sie 0 ein, um das Modell geladen zu halten, oder mindestens 60 Sekunden.",
},
previewSharing: {
sectionTitle: "Vorschau-Freigabe",
diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts
index fe3a6f8542..de8ac17c29 100644
--- a/studio/frontend/src/i18n/locales/en.ts
+++ b/studio/frontend/src/i18n/locales/en.ts
@@ -224,14 +224,14 @@ export const en = {
"When an OpenAI-compatible request names a different downloaded GGUF, load it before serving. Off by default; unknown names keep serving the loaded model.",
idleUnload: "Idle auto-unload",
idleUnloadDescription:
- "Unload the model after this many idle seconds to free VRAM; the next request reloads it. 0 keeps it loaded.",
+ "Unload the model after this many idle seconds to free VRAM; the next request reloads it. 0 keeps it loaded. Minimum 60 seconds.",
idleNeedsEnable:
"Turn on Switch model by request so an unloaded model reloads on next use.",
idleActiveViaEnv:
"Idle auto-unload is active via the UNSLOTH_MODEL_IDLE_TTL environment variable.",
loadError: "Failed to load model auto-switch settings.",
saveError: "Failed to save model auto-switch settings.",
- idleError: "Enter a whole number of seconds (0 or more).",
+ idleError: "Enter 0 to keep the model loaded, or at least 60 seconds.",
},
previewSharing: {
sectionTitle: "Preview sharing",
diff --git a/studio/frontend/src/i18n/locales/es.ts b/studio/frontend/src/i18n/locales/es.ts
index e5f9650bef..988c109a3f 100644
--- a/studio/frontend/src/i18n/locales/es.ts
+++ b/studio/frontend/src/i18n/locales/es.ts
@@ -157,7 +157,7 @@ export const es = {
"Cuando una solicitud compatible con OpenAI nombra un GGUF descargado distinto, se carga antes de responder. Desactivado por defecto; los nombres desconocidos siguen usando el modelo cargado.",
idleUnload: "Descarga automática por inactividad",
idleUnloadDescription:
- "Descarga el modelo tras este número de segundos inactivo para liberar VRAM; la siguiente solicitud lo recarga. 0 lo mantiene cargado.",
+ "Descarga el modelo tras este número de segundos inactivo para liberar VRAM; la siguiente solicitud lo recarga. 0 lo mantiene cargado. Mínimo 60 segundos.",
idleNeedsEnable:
"Activa Cambiar de modelo según la solicitud para que un modelo descargado se recargue en el próximo uso.",
idleActiveViaEnv:
@@ -166,7 +166,7 @@ export const es = {
"No se pudo cargar la configuración de cambio automático de modelo.",
saveError:
"No se pudo guardar la configuración de cambio automático de modelo.",
- idleError: "Introduce un número entero de segundos (0 o más).",
+ idleError: "Introduce 0 para mantener el modelo cargado, o al menos 60 segundos.",
},
previewSharing: {
sectionTitle: "Compartir vista previa",
diff --git a/studio/frontend/src/i18n/locales/fr.ts b/studio/frontend/src/i18n/locales/fr.ts
index 190284175d..e1f2a0c5ec 100644
--- a/studio/frontend/src/i18n/locales/fr.ts
+++ b/studio/frontend/src/i18n/locales/fr.ts
@@ -157,7 +157,7 @@ export const fr = {
"Lorsqu'une requête compatible OpenAI nomme un autre GGUF téléchargé, le charger avant de répondre. Désactivé par défaut ; les noms inconnus continuent de servir le modèle chargé.",
idleUnload: "Déchargement automatique en cas d'inactivité",
idleUnloadDescription:
- "Décharger le modèle après ce nombre de secondes d'inactivité pour libérer la VRAM ; la requête suivante le recharge. 0 le maintient chargé.",
+ "Décharger le modèle après ce nombre de secondes d'inactivité pour libérer la VRAM ; la requête suivante le recharge. 0 le maintient chargé. Minimum 60 secondes.",
idleNeedsEnable:
"Activez Changer de modèle par requête pour qu'un modèle déchargé se recharge à la prochaine utilisation.",
idleActiveViaEnv:
@@ -166,7 +166,7 @@ export const fr = {
"Échec du chargement des paramètres de changement automatique de modèle.",
saveError:
"Échec de l'enregistrement des paramètres de changement automatique de modèle.",
- idleError: "Saisissez un nombre entier de secondes (0 ou plus).",
+ idleError: "Saisissez 0 pour garder le modèle chargé, ou au moins 60 secondes.",
},
previewSharing: {
sectionTitle: "Partage de l'aperçu",
diff --git a/studio/frontend/src/i18n/locales/hi.ts b/studio/frontend/src/i18n/locales/hi.ts
index 97f55251c5..77b6265e7b 100644
--- a/studio/frontend/src/i18n/locales/hi.ts
+++ b/studio/frontend/src/i18n/locales/hi.ts
@@ -154,14 +154,14 @@ export const hi = {
"जब कोई OpenAI-संगत अनुरोध किसी अन्य डाउनलोड किए गए GGUF का नाम लेता है, तो सर्व करने से पहले उसे लोड करें। डिफ़ॉल्ट रूप से बंद; अज्ञात नाम लोड किए गए मॉडल को सर्व करते रहते हैं।",
idleUnload: "निष्क्रिय ऑटो-अनलोड",
idleUnloadDescription:
- "VRAM मुक्त करने के लिए इतने निष्क्रिय सेकंड के बाद मॉडल को अनलोड करें; अगला अनुरोध इसे फिर से लोड करता है। 0 इसे लोड रखता है।",
+ "VRAM मुक्त करने के लिए इतने निष्क्रिय सेकंड के बाद मॉडल को अनलोड करें; अगला अनुरोध इसे फिर से लोड करता है। 0 इसे लोड रखता है। न्यूनतम 60 सेकंड।",
idleNeedsEnable:
"अनुरोध के अनुसार मॉडल बदलें चालू करें ताकि अनलोड किया गया मॉडल अगले उपयोग पर फिर से लोड हो।",
idleActiveViaEnv:
"निष्क्रिय ऑटो-अनलोड UNSLOTH_MODEL_IDLE_TTL एनवायरनमेंट वेरिएबल के माध्यम से सक्रिय है।",
loadError: "मॉडल ऑटो-स्विच सेटिंग्स लोड करने में विफल।",
saveError: "मॉडल ऑटो-स्विच सेटिंग्स सहेजने में विफल।",
- idleError: "सेकंड की पूरी संख्या दर्ज करें (0 या अधिक)।",
+ idleError: "मॉडल को लोड रखने के लिए 0 दर्ज करें, या कम से कम 60 सेकंड।",
},
previewSharing: {
sectionTitle: "पूर्वावलोकन साझाकरण",
diff --git a/studio/frontend/src/i18n/locales/ja.ts b/studio/frontend/src/i18n/locales/ja.ts
index 23752b6c26..a261994f03 100644
--- a/studio/frontend/src/i18n/locales/ja.ts
+++ b/studio/frontend/src/i18n/locales/ja.ts
@@ -149,12 +149,12 @@ export const ja = {
enable: "リクエストごとにモデルを切り替え",
enableDescription: "OpenAI互換のリクエストが別のダウンロード済み GGUF を指定した場合、応答する前にそのモデルを読み込みます。デフォルトはオフです。不明な名前の場合は、読み込み済みのモデルで応答を続けます。",
idleUnload: "アイドル時の自動アンロード",
- idleUnloadDescription: "指定した秒数だけアイドル状態が続くとモデルをアンロードして VRAM を解放します。次のリクエストで再読み込みされます。0 にすると読み込んだままにします。",
+ idleUnloadDescription: "指定した秒数だけアイドル状態が続くとモデルをアンロードして VRAM を解放します。次のリクエストで再読み込みされます。0 にすると読み込んだままにします。最小 60 秒。",
idleNeedsEnable: "アンロードされたモデルが次回使用時に再読み込みされるように、「リクエストごとにモデルを切り替え」をオンにしてください。",
idleActiveViaEnv: "アイドル時の自動アンロードは UNSLOTH_MODEL_IDLE_TTL 環境変数によって有効になっています。",
loadError: "モデル自動切り替え設定の読み込みに失敗しました。",
saveError: "モデル自動切り替え設定の保存に失敗しました。",
- idleError: "秒数を整数(0 以上)で入力してください。",
+ idleError: "モデルを読み込んだままにするには 0 を、それ以外は 60 秒以上を入力してください。",
},
previewSharing: {
sectionTitle: "プレビュー共有",
diff --git a/studio/frontend/src/i18n/locales/ko.ts b/studio/frontend/src/i18n/locales/ko.ts
index a11a94faf2..7c5691925e 100644
--- a/studio/frontend/src/i18n/locales/ko.ts
+++ b/studio/frontend/src/i18n/locales/ko.ts
@@ -153,14 +153,14 @@ export const ko = {
"OpenAI 호환 요청이 다운로드된 다른 GGUF를 지정하면, 응답하기 전에 해당 모델을 불러옵니다. 기본값은 꺼짐이며, 알 수 없는 이름은 불러온 모델을 계속 제공합니다.",
idleUnload: "유휴 시 자동 해제",
idleUnloadDescription:
- "지정한 유휴 시간(초)이 지나면 모델을 해제하여 VRAM을 확보합니다. 다음 요청 시 다시 불러옵니다. 0으로 설정하면 계속 로드된 상태로 유지됩니다.",
+ "지정한 유휴 시간(초)이 지나면 모델을 해제하여 VRAM을 확보합니다. 다음 요청 시 다시 불러옵니다. 0으로 설정하면 계속 로드된 상태로 유지됩니다. 최소 60초입니다.",
idleNeedsEnable:
"해제된 모델이 다음 사용 시 다시 로드되도록 하려면 요청에 따라 모델 전환을 켜세요.",
idleActiveViaEnv:
"유휴 시 자동 해제가 UNSLOTH_MODEL_IDLE_TTL 환경 변수를 통해 활성화되어 있습니다.",
loadError: "모델 자동 전환 설정을 불러오지 못했습니다.",
saveError: "모델 자동 전환 설정을 저장하지 못했습니다.",
- idleError: "정수(초)를 입력하세요(0 이상).",
+ idleError: "모델을 로드 상태로 유지하려면 0을, 그렇지 않으면 60초 이상을 입력하세요.",
},
previewSharing: {
sectionTitle: "미리보기 공유",
diff --git a/studio/frontend/src/i18n/locales/pt-br.ts b/studio/frontend/src/i18n/locales/pt-br.ts
index 07832ef1d0..e6d2347c10 100644
--- a/studio/frontend/src/i18n/locales/pt-br.ts
+++ b/studio/frontend/src/i18n/locales/pt-br.ts
@@ -157,14 +157,14 @@ export const ptBR = {
"Quando uma requisição compatível com OpenAI nomear um GGUF baixado diferente, carrega-o antes de responder. Desativado por padrão; nomes desconhecidos continuam usando o modelo carregado.",
idleUnload: "Descarregamento automático por inatividade",
idleUnloadDescription:
- "Descarrega o modelo após esta quantidade de segundos de inatividade para liberar VRAM; a próxima requisição o recarrega. 0 mantém o modelo carregado.",
+ "Descarrega o modelo após esta quantidade de segundos de inatividade para liberar VRAM; a próxima requisição o recarrega. 0 mantém o modelo carregado. Mínimo de 60 segundos.",
idleNeedsEnable:
"Ative Trocar de modelo por requisição para que um modelo descarregado seja recarregado no próximo uso.",
idleActiveViaEnv:
"O descarregamento automático por inatividade está ativo por meio da variável de ambiente UNSLOTH_MODEL_IDLE_TTL.",
loadError: "Falha ao carregar as configurações de troca automática de modelo.",
saveError: "Falha ao salvar as configurações de troca automática de modelo.",
- idleError: "Insira um número inteiro de segundos (0 ou mais).",
+ idleError: "Insira 0 para manter o modelo carregado, ou pelo menos 60 segundos.",
},
previewSharing: {
sectionTitle: "Compartilhamento de pré-visualização",
diff --git a/studio/frontend/src/i18n/locales/ru.ts b/studio/frontend/src/i18n/locales/ru.ts
index 81d20cc2ea..c7464a3b44 100644
--- a/studio/frontend/src/i18n/locales/ru.ts
+++ b/studio/frontend/src/i18n/locales/ru.ts
@@ -154,14 +154,14 @@ export const ru = {
"Когда OpenAI-совместимый запрос указывает другую загруженную GGUF, загружать её перед обслуживанием. По умолчанию выключено; неизвестные имена продолжают обслуживать загруженную модель.",
idleUnload: "Автовыгрузка при простое",
idleUnloadDescription:
- "Выгружать модель после указанного числа секунд простоя, чтобы освободить VRAM; следующий запрос загрузит её снова. 0 оставляет модель загруженной.",
+ "Выгружать модель после указанного числа секунд простоя, чтобы освободить VRAM; следующий запрос загрузит её снова. 0 оставляет модель загруженной. Минимум 60 секунд.",
idleNeedsEnable:
"Включите «Переключать модель по запросу», чтобы выгруженная модель загружалась при следующем использовании.",
idleActiveViaEnv:
"Автовыгрузка при простое активна через переменную окружения UNSLOTH_MODEL_IDLE_TTL.",
loadError: "Не удалось загрузить настройки автопереключения модели.",
saveError: "Не удалось сохранить настройки автопереключения модели.",
- idleError: "Введите целое число секунд (0 или больше).",
+ idleError: "Введите 0, чтобы модель оставалась загруженной, или не менее 60 секунд.",
},
previewSharing: {
sectionTitle: "Публикация предпросмотра",
diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts
index 4c51755244..ff218adad2 100644
--- a/studio/frontend/src/i18n/locales/zh-CN.ts
+++ b/studio/frontend/src/i18n/locales/zh-CN.ts
@@ -152,14 +152,14 @@ export const zhCN = {
"当兼容 OpenAI 的请求指定了另一个已下载的 GGUF 时,先加载它再提供服务。默认关闭;未知名称将继续使用已加载的模型。",
idleUnload: "空闲自动卸载",
idleUnloadDescription:
- "空闲达到该秒数后卸载模型以释放 VRAM;下次请求会重新加载。设为 0 则保持加载。",
+ "空闲达到该秒数后卸载模型以释放 VRAM;下次请求会重新加载。设为 0 则保持加载。最小 60 秒。",
idleNeedsEnable:
"开启“按请求切换模型”,以便已卸载的模型在下次使用时重新加载。",
idleActiveViaEnv:
"空闲自动卸载已通过 UNSLOTH_MODEL_IDLE_TTL 环境变量启用。",
loadError: "加载模型自动切换设置失败。",
saveError: "保存模型自动切换设置失败。",
- idleError: "请输入整数秒数(0 或以上)。",
+ idleError: "输入 0 保持模型加载,或输入至少 60 秒。",
},
previewSharing: {
sectionTitle: "预览分享",
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css
index 159eba743f..6d4c21eec8 100644
--- a/studio/frontend/src/index.css
+++ b/studio/frontend/src/index.css
@@ -1467,7 +1467,9 @@ html[data-chat-font] .aui-root {
}
/* With more than 4 tools on, drop pill labels to icons only to cut clutter.
- Compare keeps its label via data-keep-label. */
+ Compare and the bypass-permissions pill keep their labels via
+ data-keep-label; the permission pill sits before the collapsed icons, so
+ they line up to its right. */
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label])
> span:not(.composer-pill-glyph) {
@@ -2048,15 +2050,11 @@ html[data-chat-font] .aui-root {
}
}
- /* Fine-tuning Studio: equal default height, expandable when needed (md+) */
+ /* Fine-tuning Studio: equal minimum card height, grows with content (md+) */
.min-h-studio-config-column {
@apply md:min-h-[520px];
}
- .h-studio-config-column {
- @apply md:h-[520px];
- }
-
[data-streamdown="unordered-list"] {
list-style-type: disc;
list-style-position: outside;
diff --git a/tests/python/test_get_lora_parameters_bias_fp8_block_size.py b/tests/python/test_get_lora_parameters_bias_fp8_block_size.py
new file mode 100644
index 0000000000..835ad19542
--- /dev/null
+++ b/tests/python/test_get_lora_parameters_bias_fp8_block_size.py
@@ -0,0 +1,82 @@
+import ast
+from pathlib import Path
+
+
+def _load_function(name):
+ # Extract a function from kernels/utils.py without importing unsloth (which
+ # needs a GPU / torch / bitsandbytes). The functions exercised here only use
+ # getattr and the _FP8_WEIGHT_DTYPES name on the paths under test.
+ source = Path(__file__).parents[2] / "unsloth" / "kernels" / "utils.py"
+ tree = ast.parse(source.read_text(encoding = "utf-8"))
+ funcs = [
+ node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == name
+ ]
+ assert len(funcs) == 1, (name, funcs)
+ namespace = {"getattr": getattr, "_FP8_WEIGHT_DTYPES": ()}
+ module = ast.Module(body = funcs, type_ignores = [])
+ ast.fix_missing_locations(module)
+ exec(compile(module, str(source), "exec"), namespace)
+ return namespace[name]
+
+
+class _Obj:
+ pass
+
+
+def _make_disabled_block_fp8_proj(block_size):
+ # A merged/disabled projection whose base layer is a block-fp8 weight that
+ # ships a non-default block size on its checkpoint.
+ weight = _Obj()
+ weight.quant_state = _Obj()
+ base_layer = _Obj()
+ base_layer.weight = weight
+ base_layer.quant_method = "fp8"
+ base_layer.block_size = block_size
+ base_layer.bias = None
+ proj = _Obj()
+ proj.base_layer = base_layer
+ proj.merged = True
+ proj.disable_adapters = True
+ return proj, weight.quant_state
+
+
+def test_bias_variant_propagates_fp8_block_size_on_disabled_path():
+ # Downstream fp8 kernels read getattr(weight_scale, "block_size", [128, 128]),
+ # so the checkpoint's real block size must survive the merged/disabled path,
+ # exactly as it does for the non-bias sibling get_lora_parameters.
+ get_lora_parameters_bias = _load_function("get_lora_parameters_bias")
+
+ proj, weight_scale = _make_disabled_block_fp8_proj([64, 128])
+ get_lora_parameters_bias(proj)
+
+ assert getattr(weight_scale, "block_size", [128, 128]) == [64, 128]
+
+
+def _make_decompressed_merged_proj():
+ # A merged compressed-tensors layer that was decompressed back to bf16. It keeps
+ # quant_method == "fp8" from the checkpoint metadata, but the live weight is bf16
+ # so there is no quant state to attach a block size to.
+ weight = _Obj()
+ weight.dtype = "bfloat16"
+ base_layer = _Obj()
+ base_layer.weight = weight
+ base_layer.quant_method = "fp8"
+ base_layer.block_size = [128, 128]
+ base_layer.bias = None
+ proj = _Obj()
+ proj.base_layer = base_layer
+ proj.merged = True
+ proj.disable_adapters = True
+ return proj
+
+
+def test_bias_variant_keeps_none_quant_state_for_decompressed_layer():
+ # Such a layer has no quant state, and fast_linear_forward relies on getting
+ # W_quant None back so it can fall back to a plain matmul, so setting the block
+ # size must not assume a quant state is present.
+ get_lora_parameters_bias = _load_function("get_lora_parameters_bias")
+
+ W, W_quant = get_lora_parameters_bias(_make_decompressed_merged_proj())[:2]
+
+ assert W_quant is None
+ assert getattr(W, "block_size", None) == [128, 128]
diff --git a/tests/python/test_get_lora_parameters_fp8_block_size.py b/tests/python/test_get_lora_parameters_fp8_block_size.py
new file mode 100644
index 0000000000..f5f1359125
--- /dev/null
+++ b/tests/python/test_get_lora_parameters_fp8_block_size.py
@@ -0,0 +1,81 @@
+import ast
+from pathlib import Path
+
+
+def _load_function(name):
+ # Extract a function from kernels/utils.py without importing unsloth (which
+ # needs a GPU / torch / bitsandbytes). get_lora_parameters only uses getattr,
+ # hasattr and the _FP8_WEIGHT_DTYPES name on the paths under test.
+ source = Path(__file__).parents[2] / "unsloth" / "kernels" / "utils.py"
+ tree = ast.parse(source.read_text(encoding = "utf-8"))
+ funcs = [
+ node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == name
+ ]
+ assert len(funcs) == 1, (name, funcs)
+ namespace = {"getattr": getattr, "hasattr": hasattr, "_FP8_WEIGHT_DTYPES": ()}
+ module = ast.Module(body = funcs, type_ignores = [])
+ ast.fix_missing_locations(module)
+ exec(compile(module, str(source), "exec"), namespace)
+ return namespace[name]
+
+
+class _Obj:
+ pass
+
+
+def _make_disabled_block_fp8_proj(block_size):
+ # A merged/disabled projection whose base layer is a block-fp8 weight that
+ # ships a non-default block size on its checkpoint.
+ weight = _Obj()
+ weight.quant_state = _Obj()
+ base_layer = _Obj()
+ base_layer.weight = weight
+ base_layer.quant_method = "fp8"
+ base_layer.block_size = block_size
+ proj = _Obj()
+ proj.base_layer = base_layer
+ proj.merged = True
+ proj.disable_adapters = True
+ return proj, weight.quant_state
+
+
+def test_propagates_fp8_block_size_on_disabled_path():
+ # get_lora_parameters already sets block_size before its early return; downstream
+ # fp8 kernels read getattr(weight_scale, "block_size", [128, 128]), so the
+ # checkpoint's real block size must survive the merged/disabled path.
+ get_lora_parameters = _load_function("get_lora_parameters")
+
+ proj, weight_scale = _make_disabled_block_fp8_proj([64, 128])
+ get_lora_parameters(proj)
+
+ assert getattr(weight_scale, "block_size", [128, 128]) == [64, 128]
+
+
+def _make_decompressed_merged_proj():
+ # A merged compressed-tensors layer that was decompressed back to bf16. It keeps
+ # quant_method == "fp8" from the checkpoint metadata, but the live weight is bf16
+ # so there is no quant state to attach a block size to.
+ weight = _Obj()
+ weight.dtype = "bfloat16"
+ base_layer = _Obj()
+ base_layer.weight = weight
+ base_layer.quant_method = "fp8"
+ base_layer.block_size = [128, 128]
+ proj = _Obj()
+ proj.base_layer = base_layer
+ proj.merged = True
+ proj.disable_adapters = True
+ return proj
+
+
+def test_keeps_none_quant_state_for_decompressed_layer():
+ # Mirrors the get_lora_parameters_bias guard: with no quant state, assigning
+ # W_quant.block_size must not assume one is present, or it raises AttributeError
+ # on None. fast_lora relies on getting W_quant None back to fall back to a plain
+ # matmul, so this path must stay crash-free.
+ get_lora_parameters = _load_function("get_lora_parameters")
+
+ W, W_quant = get_lora_parameters(_make_decompressed_merged_proj())[:2]
+
+ assert W_quant is None
+ assert getattr(W, "block_size", None) == [128, 128]
diff --git a/tests/saving/test_export_dispatch.py b/tests/saving/test_export_dispatch.py
index 3870d6269d..ad6d51e0b7 100644
--- a/tests/saving/test_export_dispatch.py
+++ b/tests/saving/test_export_dispatch.py
@@ -8,6 +8,8 @@ regressions that pure AST checks cannot (e.g. wrong scheme/suffix/outtype passed
from __future__ import annotations
+import inspect
+
import pytest
import unsloth.save as save_mod
@@ -126,6 +128,80 @@ def test_gguf_lora_push_to_hub_is_rejected(tmp_path):
)
+# The above rejection points users at push_to_hub_gguf(save_method='lora'), so that path
+# has to work; it is only ever exercised here.
+
+
+def test_push_to_hub_gguf_lora_dispatches(monkeypatch):
+ seen = {}
+ monkeypatch.setattr(
+ save_mod,
+ "_unsloth_save_lora_gguf",
+ lambda model, tok, sd, **kw: seen.update(kw),
+ )
+ save_mod.unsloth_push_to_hub_gguf(
+ _FakeModel(),
+ "repo/id",
+ tokenizer = object(),
+ save_method = "lora",
+ quantization_method = "q8_0",
+ )
+ assert seen.get("outtype") == "q8_0"
+ assert seen.get("push_to_hub") is True
+
+
+def test_push_to_hub_gguf_lora_skips_non_main_process(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ save_mod,
+ "_unsloth_save_lora_gguf",
+ lambda *a, **kw: calls.append(kw),
+ )
+ result = save_mod.unsloth_push_to_hub_gguf(
+ _FakeModel(),
+ "repo/id",
+ tokenizer = object(),
+ save_method = "lora",
+ is_main_process = False,
+ )
+ assert result is None
+ assert calls == []
+
+
+def test_push_to_hub_gguf_skips_non_main_process_before_merged_conversion(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ save_mod,
+ "unsloth_save_pretrained_gguf",
+ lambda **kw: calls.append(kw),
+ )
+ result = save_mod.unsloth_push_to_hub_gguf(
+ _FakeModel(),
+ "repo/id",
+ tokenizer = object(),
+ is_main_process = False,
+ )
+ assert result is None
+ assert calls == []
+
+
+def test_push_to_hub_gguf_preserves_positional_max_shard_size():
+ bound = inspect.signature(save_mod.unsloth_push_to_hub_gguf).bind(
+ _FakeModel(),
+ "repo/id",
+ object(),
+ "q4_k_m",
+ None,
+ None,
+ None,
+ None,
+ "token",
+ "50GB",
+ )
+ assert bound.arguments["max_shard_size"] == "50GB"
+ assert "is_main_process" not in bound.arguments
+
+
# -- torchao PTQ / QAT dispatch ------------------------------------------------------------
diff --git a/tests/saving/test_fix_sentencepiece_tokenizer_guard.py b/tests/saving/test_fix_sentencepiece_tokenizer_guard.py
new file mode 100644
index 0000000000..1ee523d57b
--- /dev/null
+++ b/tests/saving/test_fix_sentencepiece_tokenizer_guard.py
@@ -0,0 +1,307 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import gc
+import os
+
+os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
+
+import transformers
+from transformers.utils import sentencepiece_model_pb2
+
+from unsloth.tokenizer_utils import fix_sentencepiece_tokenizer
+
+
+NORMAL, CONTROL = 1, 3
+
+
+def _spm_bytes(pieces):
+ m = sentencepiece_model_pb2.ModelProto()
+ for piece, score, typ in pieces:
+ p = m.pieces.add()
+ p.piece = piece
+ p.score = score
+ p.type = typ
+ return m.SerializeToString()
+
+
+def _read_pieces(path):
+ m = sentencepiece_model_pb2.ModelProto()
+ with open(path, "rb") as f:
+ m.ParseFromString(f.read())
+ return [p.piece for p in m.pieces]
+
+
+class _FakeTokenizer:
+ """Minimal stand-in for a sentencepiece-backed slow tokenizer.
+
+ ``save_pretrained`` writes a tokenizer.model, which is what the real slow
+ tokenizers do and what fix_sentencepiece_tokenizer reads back.
+ """
+
+ def __init__(
+ self,
+ name,
+ spm_bytes = None,
+ vocab = None,
+ ):
+ self.name = name
+ self.eos_token = ""
+ self.pad_token = ""
+ self._spm_bytes = spm_bytes
+ self._vocab = vocab or {}
+ self.saved_to = []
+
+ def save_pretrained(self, location):
+ self.saved_to.append(location)
+ os.makedirs(location, exist_ok = True)
+ if self._spm_bytes is not None:
+ with open(os.path.join(location, "tokenizer.model"), "wb") as f:
+ f.write(self._spm_bytes)
+
+ def __call__(
+ self,
+ texts,
+ add_special_tokens = False,
+ ):
+ class _Encoded:
+ pass
+
+ encoded = _Encoded()
+ encoded.input_ids = [[self._vocab[text]] for text in texts]
+ return encoded
+
+
+def _tokenizers():
+ pieces = [("", 0.0, CONTROL), ("a", -1.0, NORMAL), ("", 0.0, CONTROL)]
+ old = _FakeTokenizer("old", spm_bytes = _spm_bytes(pieces), vocab = {"": 2})
+ new = _FakeTokenizer("new")
+ return old, new
+
+
+class _ReloadedTokenizer:
+ """Weakref-able stand-in for the tokenizer AutoTokenizer.from_pretrained returns."""
+
+ def __init__(self, location):
+ self.location = location
+
+
+def _stub_auto_tokenizer(monkeypatch):
+ """fix_sentencepiece_tokenizer reloads the patched directory through
+ AutoTokenizer at the end; that needs a full tokenizer on disk, which is
+ out of scope here. Record the reload location and hand back a sentinel.
+ """
+ loaded = []
+
+ class _StubAutoTokenizer:
+ @staticmethod
+ def from_pretrained(location, **kwargs):
+ loaded.append(location)
+ return _ReloadedTokenizer(location)
+
+ monkeypatch.setattr(transformers, "AutoTokenizer", _StubAutoTokenizer)
+ return loaded
+
+
+def test_old_tokenizer_is_saved_so_its_model_can_be_read(tmp_path, monkeypatch):
+ """The guard must not skip the body on a fresh temporary directory.
+
+ fix_sentencepiece_tokenizer creates its scratch directory itself and then
+ checks for a tokenizer.model inside it, but that file only appears once
+ old_tokenizer.save_pretrained() has run.
+ """
+ _stub_auto_tokenizer(monkeypatch)
+ old, new = _tokenizers()
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+
+ fix_sentencepiece_tokenizer(old, new, {"": "<|im_end|>"}, temporary_location = location)
+
+ assert old.saved_to, "old tokenizer was never saved: the body did not run"
+
+
+def test_token_mapping_is_applied_to_the_sentencepiece_model(tmp_path, monkeypatch):
+ loaded = _stub_auto_tokenizer(monkeypatch)
+ old, new = _tokenizers()
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+
+ # Hold the returned tokenizer so its scratch dir survives until we read it.
+ tok = fix_sentencepiece_tokenizer(old, new, {"": "<|im_end|>"}, temporary_location = location)
+
+ assert "<|im_end|>" in _read_pieces(f"{loaded[-1]}/tokenizer.model")
+ assert tok is not None
+
+
+def test_tokenizer_without_a_sentencepiece_model_is_returned_untouched(tmp_path, monkeypatch):
+ """A fast-only tokenizer writes no tokenizer.model, so the guard still
+ short-circuits and the caller gets new_tokenizer back unchanged. Its scratch
+ dir is unreferenced and reclaimed immediately.
+ """
+ _stub_auto_tokenizer(monkeypatch)
+ old = _FakeTokenizer("old", spm_bytes = None)
+ new = _FakeTokenizer("new")
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+
+ result = fix_sentencepiece_tokenizer(
+ old, new, {"": "<|im_end|>"}, temporary_location = location
+ )
+
+ assert result is new
+ assert not any(
+ name.startswith("tokenizer_") for name in os.listdir(location)
+ ), "the fast-only scratch dir was not reclaimed"
+
+
+def test_each_call_uses_a_fresh_isolated_subdirectory(tmp_path, monkeypatch):
+ """Each call must work in its own unique subdirectory, so concurrent or
+ repeated calls never share scratch files, stale artifacts never leak into
+ the reload, and nothing the caller left in the scratch location is deleted.
+ """
+ loaded = _stub_auto_tokenizer(monkeypatch)
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+ os.makedirs(location, exist_ok = True)
+
+ # A pre-existing artifact in the shared scratch location.
+ marker = os.path.join(location, "leftover.json")
+ with open(marker, "w") as f:
+ f.write("{}")
+
+ old1, new1 = _tokenizers()
+ old2, new2 = _tokenizers()
+ # Hold both returned tokenizers so their scratch dirs stay alive.
+ tok1 = fix_sentencepiece_tokenizer(
+ old1, new1, {"": "<|im_end|>"}, temporary_location = location
+ )
+ tok2 = fix_sentencepiece_tokenizer(
+ old2, new2, {"": "<|im_end|>"}, temporary_location = location
+ )
+
+ work1, work2 = loaded[0], loaded[1]
+ assert work1 != work2, "two calls reused the same directory"
+ assert os.path.dirname(work1) == location and os.path.dirname(work2) == location
+ assert os.path.isdir(work1) and os.path.isdir(work2)
+ # Nothing the caller left behind is deleted, and it never leaks into a work dir.
+ assert os.path.isfile(marker), "a pre-existing scratch file was deleted"
+ assert not os.path.isfile(os.path.join(work1, "leftover.json"))
+ assert not os.path.isfile(os.path.join(work2, "leftover.json"))
+ assert tok1 is not None and tok2 is not None
+
+
+def test_sentencepiece_scratch_dir_is_reclaimed_once_the_tokenizer_is_gone(tmp_path, monkeypatch):
+ """The scratch dir must live as long as the returned tokenizer (its vocab_file
+ points there), then be reclaimed when the tokenizer is garbage collected.
+ """
+ loaded = _stub_auto_tokenizer(monkeypatch)
+ old, new = _tokenizers()
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+
+ tok = fix_sentencepiece_tokenizer(old, new, {"": "<|im_end|>"}, temporary_location = location)
+ work = loaded[-1]
+ assert os.path.isdir(work), "scratch dir vanished while the tokenizer was alive"
+
+ del tok
+ gc.collect()
+ assert not os.path.isdir(work), "scratch dir was not reclaimed after the tokenizer was freed"
+
+
+class _CopyFromSubdirTokenizer:
+ """A slow tokenizer whose sentencepiece source lives elsewhere (like the
+ tokenizers convert_to_fast_tokenizer produces under {location}/{name}).
+ save_pretrained copies that source into the destination, as HF slow
+ tokenizers copy their vocab_file.
+ """
+
+ def __init__(self, source_model_path):
+ self.eos_token = ""
+ self.pad_token = ""
+ self._source_model_path = source_model_path
+
+ def save_pretrained(self, location):
+ os.makedirs(location, exist_ok = True)
+ if os.path.isfile(self._source_model_path):
+ with open(self._source_model_path, "rb") as src:
+ data = src.read()
+ with open(os.path.join(location, "tokenizer.model"), "wb") as dst:
+ dst.write(data)
+
+ def __call__(
+ self,
+ texts,
+ add_special_tokens = False,
+ ):
+ class _Encoded:
+ pass
+
+ encoded = _Encoded()
+ encoded.input_ids = [[2] for _ in texts]
+ return encoded
+
+
+def test_source_vocab_outside_the_work_directory_is_not_disturbed(tmp_path, monkeypatch):
+ """A tokenizer whose sentencepiece source lives elsewhere (e.g. the subtree
+ convert_to_fast_tokenizer created) is copied into the fresh work directory
+ and patched there; the original source is left untouched.
+ """
+ loaded = _stub_auto_tokenizer(monkeypatch)
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+ subdir = os.path.join(location, "some_model")
+ os.makedirs(subdir, exist_ok = True)
+
+ pieces = [("", 0.0, CONTROL), ("a", -1.0, NORMAL), ("", 0.0, CONTROL)]
+ source_model = os.path.join(subdir, "tokenizer.model")
+ with open(source_model, "wb") as f:
+ f.write(_spm_bytes(pieces))
+
+ old = _CopyFromSubdirTokenizer(source_model)
+ new = _FakeTokenizer("new")
+ tok = fix_sentencepiece_tokenizer(old, new, {"": "<|im_end|>"}, temporary_location = location)
+
+ assert _read_pieces(source_model) == [
+ "",
+ "a",
+ "",
+ ], "the original source vocab was modified"
+ assert "<|im_end|>" in _read_pieces(f"{loaded[-1]}/tokenizer.model")
+ assert tok is not None
+
+
+def test_swap_mapping_swaps_both_pieces_without_duplicating(tmp_path, monkeypatch):
+ """When the caller swaps eos and stop_word in the fast JSON it must pass both
+ directions here; a one-way mapping would leave two stop_word pieces and no eos.
+ """
+ loaded = _stub_auto_tokenizer(monkeypatch)
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+
+ pieces = [("", 0.0, CONTROL), ("<|im_end|>", -1.0, NORMAL), ("", 0.0, CONTROL)]
+ old = _FakeTokenizer("old", spm_bytes = _spm_bytes(pieces), vocab = {"": 2, "<|im_end|>": 1})
+ new = _FakeTokenizer("new")
+
+ tok = fix_sentencepiece_tokenizer(
+ old, new, {"": "<|im_end|>", "<|im_end|>": ""}, temporary_location = location
+ )
+
+ result = _read_pieces(f"{loaded[-1]}/tokenizer.model")
+ assert result.count("<|im_end|>") == 1 and result.count("") == 1, result
+ assert tok is not None
+
+
+def test_only_applied_mappings_are_patched(tmp_path, monkeypatch):
+ """When the caller skips a mapping whose target already exists, it must not
+ pass that mapping here, or the skipped source token gets renamed anyway and
+ duplicates the existing target in the model.
+ """
+ loaded = _stub_auto_tokenizer(monkeypatch)
+ location = str(tmp_path / "_unsloth_sentencepiece_temp")
+
+ pieces = [
+ ("", 0.0, CONTROL),
+ ("aa", -1.0, NORMAL),
+ ("bb", -1.0, NORMAL),
+ ("X", -1.0, NORMAL),
+ ]
+ old = _FakeTokenizer("old", spm_bytes = _spm_bytes(pieces), vocab = {"aa": 1, "bb": 2})
+ new = _FakeTokenizer("new")
+
+ # Caller skipped aa->X (X already exists) and applied bb->Y, so only bb->Y is passed.
+ tok = fix_sentencepiece_tokenizer(old, new, {"bb": "Y"}, temporary_location = location)
+
+ result = _read_pieces(f"{loaded[-1]}/tokenizer.model")
+ assert result.count("X") == 1 and "Y" in result and "aa" in result, result
+ assert tok is not None
diff --git a/tests/studio/test_studio_text_descender_clipping.py b/tests/studio/test_studio_text_descender_clipping.py
index 7b9b375374..73d1244ee3 100644
--- a/tests/studio/test_studio_text_descender_clipping.py
+++ b/tests/studio/test_studio_text_descender_clipping.py
@@ -34,19 +34,22 @@ def test_model_selector_trigger_label_uses_leading_tight():
def test_sidebar_account_block_uses_leading_tight():
src = _read(APP_SIDEBAR)
- # Match class membership without assuming utility order.
- pattern = re.compile(
- r'