diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index 44282b2255..612d739806 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -1,134 +1,145 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "view-in-github", - "colab_type": "text" - }, - "source": [ - "\"Open" - ] + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "view-in-github", + "colab_type": "text" + }, + "source": [ + "\"Open" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6b87de59" + }, + "source": [ + "To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n", + "
\n", + "\n", + "\n", + " Join Discord if you need help + ⭐ Star us on Github ⭐\n", + "
\n", + "\n", + "To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n", + "\n", + "### Unsloth Studio\n", + "\n", + "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n", + "\n", + "\n", + "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", + "\n", + "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Unsloth Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" + ], + "id": "6b87de59" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "e4206349" + }, + "source": [ + "

" + ], + "id": "e4206349" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "27da2957" + }, + "source": [ + "### Setup: Clone repo and run setup" + ], + "id": "27da2957" + }, + { + "cell_type": "code", + "metadata": { + "id": "27e68f91" + }, + "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local", + "execution_count": null, + "outputs": [], + "id": "27e68f91" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "3e1771a9" + }, + "source": [ + "### Start Unsloth Studio" + ], + "id": "3e1771a9" + }, + { + "cell_type": "code", + "metadata": { + "id": "277e431e" + }, + "source": [ + "import sys\n", + "sys.path.insert(0, \"/content/unsloth/studio/backend\")\n", + "from colab import start\n", + "\n", + "# On Colab, start() auto-opens a Cloudflare link and prints admin login credentials.\n", + "# Use the Cloudflare link above the ready card to open Studio (in-cell iframes often stay blank).\n", + "start()\n", + "\n", + "# To skip the Cloudflare tunnel and try the in-notebook proxy iframe only:\n", + "# start(cloudflare=False)" + ], + "execution_count": null, + "outputs": [], + "id": "277e431e" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "f2b0c6a1" + }, + "source": [ + "And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n", + "\n", + "Some other resources:\n", + "1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n", + "2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n", + "3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n", + "4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n", + "5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n", + "\n", + "
\n", + " \n", + " \n", + " \n", + "\n", + " Join Discord if you need help + ⭐️ Star us on Github ⭐️\n", + "\n", + " This notebook is licensed AGPL-3.0\n", + "
" + ], + "id": "f2b0c6a1" + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "include_colab_link": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } }, - { - "cell_type": "markdown", - "id": "6b87de59", - "metadata": { - "id": "6b87de59" - }, - "source": [ - "To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n", - "
\n", - "\n", - "\n", - " Join Discord if you need help + ⭐ Star us on Github ⭐\n", - "
\n", - "\n", - "To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n", - "\n", - "### Unsloth Studio\n", - "\n", - "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n", - "\n", - "\n", - "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", - "\n", - "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Unsloth Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" - ] - }, - { - "cell_type": "markdown", - "id": "e4206349", - "metadata": { - "id": "e4206349" - }, - "source": [ - "

" - ] - }, - { - "cell_type": "markdown", - "id": "27da2957", - "metadata": { - "id": "27da2957" - }, - "source": [ - "### Setup: Clone repo and run setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27e68f91", - "metadata": { - "id": "27e68f91" - }, - "outputs": [], - "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local" - }, - { - "cell_type": "markdown", - "id": "3e1771a9", - "metadata": { - "id": "3e1771a9" - }, - "source": [ - "### Start Unsloth Studio" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "277e431e", - "metadata": { - "id": "277e431e" - }, - "outputs": [], - "source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\n\n# Default: in-tab iframe only. start() blocks to keep the kernel alive.\nstart()\n\n# For a shareable Cloudflare link, replace start() above with:\n# start(cloudflare=True)" - }, - { - "cell_type": "markdown", - "id": "f2b0c6a1", - "metadata": { - "id": "f2b0c6a1" - }, - "source": [ - "And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n", - "\n", - "Some other resources:\n", - "1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n", - "2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n", - "3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n", - "4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n", - "5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n", - "\n", - "
\n", - " \n", - " \n", - " \n", - "\n", - " Join Discord if you need help + ⭐️ Star us on Github ⭐️\n", - "\n", - " This notebook is licensed AGPL-3.0\n", - "
" - ] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": { - "gpuType": "T4", - "provenance": [], - "include_colab_link": true - }, - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 + "nbformat": 4, + "nbformat_minor": 5 } \ No newline at end of file diff --git a/studio/backend/assets/configs/full_finetune.yaml b/studio/backend/assets/configs/full_finetune.yaml index e398515f61..98c45dd851 100644 --- a/studio/backend/assets/configs/full_finetune.yaml +++ b/studio/backend/assets/configs/full_finetune.yaml @@ -30,6 +30,7 @@ lora: vision_all_linear: false use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/lora_text.yaml b/studio/backend/assets/configs/lora_text.yaml index 9cb6b8c700..6c6a4d8839 100644 --- a/studio/backend/assets/configs/lora_text.yaml +++ b/studio/backend/assets/configs/lora_text.yaml @@ -30,6 +30,7 @@ lora: vision_all_linear: false use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/default.yaml b/studio/backend/assets/configs/model_defaults/default.yaml index 841e8ba166..e569031a31 100644 --- a/studio/backend/assets/configs/model_defaults/default.yaml +++ b/studio/backend/assets/configs/model_defaults/default.yaml @@ -33,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml index f7b49c75b7..7ac1c83e04 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml index be7da0f624..4cab9e9f96 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml @@ -30,6 +30,7 @@ lora: - "query" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml index d9e49bc0d5..c1f1c2a344 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml @@ -30,6 +30,7 @@ lora: - "value" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml index c3422d399f..7828feae81 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml @@ -33,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml index 529a56a527..5a4028f15b 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml @@ -29,6 +29,7 @@ lora: - "Wqkv" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml index 734115ec41..7645d11c98 100644 --- a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml index 1032449e8c..b746235f1f 100644 --- a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml index c8e5f35841..4964fea276 100644 --- a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml index 251409c29d..e5f3344356 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml index 89b1d7f938..71c61f383a 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml index e3292b5972..3fe29cd800 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml @@ -33,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml index 98fe497912..cd4e3e0c4d 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml index bda5471643..97aa10e861 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml index 18392568bd..a1b1640fa2 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml index 434ac41b46..dbf60f04d4 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml index 5f0a7b26ce..54c7dd6cd4 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml index dd5ae51ab0..119440a585 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml index e53e163a04..d08e5e9547 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml index ebe344e382..a266d7a39b 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml index fb89a07133..970cac3259 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml index 4a089992ac..5bba4ccdc0 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml index ae7524b7c6..ac5c6eca22 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml index 10c1abd8a5..68c2d35644 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml index fb5c1d9dea..175f9c0f17 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml index 189e5dc6b2..4f3834e7c0 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml index aa51440b6a..d6d97f7e44 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml @@ -26,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml index e2d67bcb0b..4f1f54a4e6 100644 --- a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml index aa436117a1..127700b53b 100644 --- a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml index 3f2cb84a94..2412b3accf 100644 --- a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml @@ -37,6 +37,7 @@ lora: - "shared_mlp.output_linear" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml index ab756fe764..81b59c4323 100644 --- a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml @@ -37,6 +37,7 @@ lora: - "shared_mlp.output_linear" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml index 1a7a91e56f..6110d84a6c 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml index 7c7bb8dc3e..3c7fc7f238 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml index f73b0c09b6..2b0977e435 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml index ffefb29e24..1742c04a06 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml index cd986a6da1..f33726b0dd 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml index 55dd3144c6..79b30bd758 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml index 8c9cb07fb9..4ee9a5a8ed 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml index 32441c5674..da20663688 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml index 6bba9c9633..30e4440afb 100644 --- a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml +++ b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml @@ -30,6 +30,7 @@ lora: - "v_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml index f9833ce705..9bb0a93e63 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml index 0ba857cd40..ded3607a14 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml index 3476f2dd6d..2ac72f1c88 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml index eda04d21f9..a087ced1f3 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml index bcd0d20c8c..c9811f4f06 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml index 34a033e32f..e3659d9fb0 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml index 98105eaf38..ee17efc54d 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml @@ -33,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml index 72b5b018e1..ef836b9b55 100644 --- a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml @@ -33,6 +33,7 @@ lora: - "v_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml index d20751b0c7..c80fad35a8 100644 --- a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml +++ b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml @@ -38,6 +38,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml index 8a80282a2a..034b5bd131 100644 --- a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml +++ b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml @@ -37,6 +37,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml index a973c2d4e4..d1a226be79 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml @@ -35,6 +35,7 @@ lora: - "out_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml index b0feafbd6e..1b8df5ced9 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml index 2c44c91eab..cecab7f083 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml @@ -37,6 +37,7 @@ lora: - "out_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml index e1fbc08e4d..730be338cf 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml index 2abdfd8ac3..a70ac0bd49 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml @@ -33,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml index 5a3c4abb48..90ead037f6 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml @@ -38,6 +38,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml index a6ce27620f..a97c557c31 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml index 050774a8cd..6855ed6a35 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml @@ -33,6 +33,7 @@ lora: - "v_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml index c574714d78..1933fed2ba 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml index e803c842b3..fda4e64158 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml index 4de3d9437d..c3910e3e5b 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml index bb75b3ce52..765ffee938 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml @@ -36,6 +36,7 @@ lora: - "gate_up_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml index c305d328c2..39b30e9cee 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml index 6cee3d0949..f97e525798 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml index 20ba81df2c..e19b94ede2 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml index 9930786c24..982f54b32f 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml index 775c7ce08f..5242128004 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml index 856db0c1b3..3559b636c6 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml index 5900392547..3bc6d69afc 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml index bd54b1d015..604b86dacd 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml index 9feb6dcaae..daed4ebccb 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml index a40eace253..05eef89b88 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml index c130771c32..b4580e6d71 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml index 2fb3a95c30..2eceb7d0de 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml @@ -36,6 +36,7 @@ lora: - "gate_up_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml index 152f4ae06a..032091880c 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml index 94fe000708..e0e7f4ee3d 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml index 3c325485d2..bb463849ed 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml @@ -35,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml index 5b47c3bdd2..23e2b89dd0 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml @@ -29,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/vision_lora.yaml b/studio/backend/assets/configs/vision_lora.yaml index 063a970316..a06f971523 100644 --- a/studio/backend/assets/configs/vision_lora.yaml +++ b/studio/backend/assets/configs/vision_lora.yaml @@ -30,6 +30,7 @@ lora: vision_all_linear: true use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/colab.py b/studio/backend/colab.py index 1762469bcf..baa18a2fec 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -1,9 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -""" -Colab helpers for Unsloth Studio. Uses Colab's built-in proxy. -""" +"""Colab helpers for Unsloth Studio. Uses Colab's built-in proxy.""" from pathlib import Path import sys @@ -22,11 +20,9 @@ logger = get_logger(__name__) def get_colab_url(port: int = 8888) -> str: - """ - Get the Colab proxy URL for a port. + """Get the Colab proxy URL for a port. - Retries up to 3 times, validating the result is a real HTTPS Colab URL. - Falls back to http://localhost:{port} only when all attempts fail. + Retries 3x validating a real HTTPS Colab URL; falls back to localhost on failure. """ import time as _time @@ -55,28 +51,244 @@ def get_colab_url(port: int = 8888) -> str: return fallback -def show_link(port: int = 8888, *, _url: "str | None" = None): - """Display a styled clickable link to the UI. - - *_url* is an optional pre-fetched proxy URL; pass it to avoid a second eval_js round-trip. - """ - from IPython.display import display, HTML - - url = _url if _url is not None else get_colab_url(port) - - # Truncated display URL; try/except so an odd URL shape still renders the link. +def _short_colab_url(url: str, port: int) -> str: + """Truncated display form of a Colab proxy URL; falls back to the full URL.""" try: port_prefix = f"{port}-" idx = url.index(port_prefix) next_dash = url.index("-", idx + len(port_prefix)) - short_url = url[: next_dash + 1] + "..." + return url[: next_dash + 1] + "..." except (ValueError, IndexError): - short_url = url + return url - # Plain-text line so the URL shows even if HTML display fails. - logger.info(f"🌐 Unsloth Studio URL: {url}") - html = f""" +def _is_colab_proxy_url(url: str, port: int) -> bool: + """True when *url* looks like a real Colab kernel proxy, not a localhost fallback.""" + return bool(url and isinstance(url, str) and url.startswith("https://") and str(port) in url) + + +def _is_colab_runtime() -> bool: + """True on a hosted Colab notebook kernel. + + Reuses the backend's main Colab detector (``/content`` + Colab env / ``google.colab``) + instead of a single env var, which is not always present on hosted runtimes. + """ + try: + from main import _IS_COLAB + return bool(_IS_COLAB) + except Exception: + return False + + +def _colab_login_credentials_path() -> Path: + from auth.storage import DB_PATH + return DB_PATH.parent / ".colab_notebook_login" + + +def _store_colab_login_credentials(username: str, password: str) -> None: + """Persist Colab admin credentials for notebook re-runs after interrupt.""" + path = _colab_login_credentials_path() + try: + path.parent.mkdir(parents = True, exist_ok = True) + path.write_text(f"{username}\n{password}\n") + try: + import os + os.chmod(path, 0o600) + except OSError: + pass + except OSError as e: + logger.info(f"Could not persist Colab login credentials ({e}).") + + +def _load_colab_login_credentials() -> "tuple[str, str] | None": + """Return stored Colab admin credentials from a previous ``start()`` run, if any.""" + path = _colab_login_credentials_path() + try: + if not path.is_file(): + return None + lines = path.read_text().splitlines() + if len(lines) >= 2 and lines[0] and lines[1]: + return lines[0], lines[1] + except OSError as e: + logger.info(f"Could not load Colab login credentials ({e}).") + return None + + +def _clear_colab_login_credentials() -> None: + """Drop the cached Colab credentials once they no longer authenticate.""" + path = _colab_login_credentials_path() + try: + path.unlink(missing_ok = True) + except OSError as e: + logger.info(f"Could not clear Colab login credentials ({e}).") + + +def _colab_credentials_still_valid(username: str, password: str) -> bool: + """True when *password* still matches the stored admin hash. + + Guards against redisplaying a cached first-run password after the user has + changed the admin password through the app, which would print credentials + that no longer authenticate to the current Cloudflare tunnel. + """ + try: + from auth.storage import get_user_and_secret + from auth.hashing import verify_password + except Exception as e: + logger.info(f"Could not load auth to validate cached Colab credentials ({e}).") + return False + try: + row = get_user_and_secret(username) + if not row: + return False + salt, pwd_hash = row[0], row[1] + return bool(verify_password(password, salt, pwd_hash)) + except Exception as e: + logger.info(f"Could not validate cached Colab credentials ({e}).") + return False + + +def _colab_wants_cloudflare(cloudflare: "bool | None") -> bool: + """Resolve whether to open a Cloudflare tunnel. + + ``None`` auto-enables on real Colab (the in-cell proxy embed is often blank); + pass ``False`` to opt out. + """ + if cloudflare is not None: + return cloudflare + return _is_colab_runtime() + + +def _finalize_colab_admin_password() -> "tuple[str, str] | None": + """Clear the bootstrap-password gate on Colab so Cloudflare tunnels can start. + + Returns ``(username, password)`` for display in the notebook. On first run the + random admin password is finalized; on later runs (e.g. after interrupt) the + stored credentials are re-displayed so the Cloudflare link stays usable. + Anyone who can read this cell already controls the runtime. + """ + if not _is_colab_runtime(): + return None + try: + from auth.storage import ( + DEFAULT_ADMIN_USERNAME, + ensure_default_admin, + generate_bootstrap_password, + get_bootstrap_password, + requires_password_change, + update_password, + ) + except Exception as e: + logger.warning( + f"Could not load auth for Colab setup ({e}); Cloudflare link may be blocked." + ) + return None + + try: + ensure_default_admin() + username = DEFAULT_ADMIN_USERNAME + if not requires_password_change(username): + creds = _load_colab_login_credentials() + if creds is not None and _colab_credentials_still_valid(username, creds[1]): + return creds + # The admin password was changed through the app after the first run, + # so the cached copy is stale; drop it instead of printing dead credentials. + _clear_colab_login_credentials() + return None + password = get_bootstrap_password() or generate_bootstrap_password() + if not update_password(username, password): + logger.warning( + "Could not finalize Colab admin password; Cloudflare link may be blocked." + ) + return None + _store_colab_login_credentials(username, password) + return username, password + except Exception as e: + logger.warning( + f"Could not finalize Colab admin password ({e}); Cloudflare link may be blocked." + ) + return None + + +def _colab_login_html(username: str, password: str) -> str: + """Notebook card with Colab admin credentials (shown once after auto-finalize).""" + return f""" +
+

+ Unsloth Studio Login (Colab) +

+

+ Log in to Studio with the Cloudflare link above using these credentials. This cell + is visible only in your notebook session. +

+

+ Username: {username}
+ Password: {password} +

+
+ """ + + +def _show_colab_login_credentials(username: str, password: str) -> None: + """Display Colab admin credentials in the notebook output.""" + from IPython.display import HTML, display + + logger.info(f"🔐 Unsloth Studio login — user: {username}") + display(HTML(_colab_login_html(username, password))) + + +def _ready_card_html( + url: str, + port: int, + *, + has_cloudflare_link: bool = False, + cloudflare_requested: bool = False, +) -> str: + """Branded ready card for the in-notebook Studio view. + + Colab ``*.prod.colab.dev`` proxy URLs are session-scoped and 404 when opened as a + top-level tab or on another device, so never ``window.open`` them. On real Colab the + Cloudflare link is the supported entry point because in-cell proxy embeds often stay blank. + """ + short_url = _short_colab_url(url, port) + if _is_colab_runtime() or _is_colab_proxy_url(url, port): + if has_cloudflare_link: + embed_note = ( + "Open Studio with the Cloudflare link above. In-cell proxy previews on " + "current Colab often stay blank, so the tunnel link is the supported path." + ) + elif cloudflare_requested: + embed_note = ( + "Could not open a Cloudflare tunnel, so Studio may be unreachable on Colab. " + "Check the logs above and re-run this cell. Pass " + '' + "cloudflare=True after fixing any tunnel errors." + ) + else: + embed_note = ( + "Colab proxy links cannot be opened in a new tab (they 404 outside this " + 'notebook). Re-run with start(cloudflare=True) for a working link.' + ) + return f""" +
+

+ + Unsloth Studio is Ready! +

+

+ {embed_note} +

+

+ {short_url} +

+
+ """ + + return f"""

""" - display(HTML(html)) + + +def show_link( + port: int = 8888, + *, + _url: "str | None" = None, + has_cloudflare_link: bool = False, + cloudflare_requested: bool = False, +): + """Display a styled ready card for the UI. + + Colab proxy URLs are informational only (no new-tab open; they 404 outside the cell); + non-proxy URLs keep a clickable open button. *_url* is an optional pre-fetched proxy + URL to avoid a second eval_js round-trip. + """ + from IPython.display import display, HTML + + url = _url if _url is not None else get_colab_url(port) + logger.info(f"🌐 Unsloth Studio URL: {url}") + display( + HTML( + _ready_card_html( + url, + port, + has_cloudflare_link = has_cloudflare_link, + cloudflare_requested = cloudflare_requested, + ) + ) + ) + + +def _warn_colab_cloudflare_missing(*, use_cloudflare: bool, cloudflare_url: "str | None") -> None: + """Log a prominent warning when Colab expected a tunnel but none was opened.""" + if not use_cloudflare or cloudflare_url or not _is_colab_runtime(): + return + logger.warning( + "Colab Cloudflare tunnel unavailable — Studio is unlikely to be reachable in this " + "notebook. Check the logs above for tunnel or auth errors, then re-run start()." + ) def _bootstrap_password_pending() -> bool: """True while the default admin still owes a bootstrap-password change. - While pending, main.py injects that password into same-origin GETs, and a public - tunnel GET (no Origin) reads as same-origin, so sharing the link would leak admin - access. Fails safe to pending if the state cannot be read. + While pending, a public tunnel GET (no Origin) reads as same-origin and gets the + injected password, so sharing the link would leak admin access. Fails safe to pending. """ try: from auth.storage import requires_password_change, DEFAULT_ADMIN_USERNAME @@ -121,9 +370,8 @@ def _bootstrap_password_pending() -> bool: def start_cloudflare_tunnel(port: int) -> "str | None": """Open a shareable Cloudflare quick tunnel to localhost:*port*, or None. - run_server suppresses the tunnel on Colab by design, so we start it directly. - Refused while the bootstrap password is pending; any failure collapses to None - and the Colab proxy still works. + run_server suppresses the tunnel on Colab, so we start it directly. Refused while the + bootstrap password is pending; any failure collapses to None (Colab proxy still works). """ if _bootstrap_password_pending(): logger.warning( @@ -152,9 +400,9 @@ def start_cloudflare_tunnel(port: int) -> "str | None": def _publish_cloudflare_url(cloudflare_url: "str | None") -> None: """Publish a directly-started tunnel URL onto app.state so /api/health advertises it. - run_server only sets this when it opens the tunnel itself, which it skips on Colab, - so we set it here. Otherwise the frontend's API examples fall back to an - unreachable server_url. Best-effort. + run_server sets this only when it opens the tunnel itself (skipped on Colab), so we + set it here; otherwise the frontend's API examples fall back to an unreachable + server_url. Best-effort. """ if not cloudflare_url: return @@ -183,8 +431,7 @@ def _stop_cloudflare_tunnel() -> None: def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool: """True only if Unsloth Studio (not some other app) answers /api/health on *port*. - The service-marker check stops the reuse path reusing or tunneling a foreign - process that merely serves /api/health. + The service-marker check stops the reuse path reusing or tunneling a foreign process. """ import json, urllib.request try: @@ -222,31 +469,45 @@ def _shareable_link_html(cloudflare_url: str) -> str: """ -def _show_and_embed(port: int, *, cloudflare_url: "str | None" = None): - """Render the Unsloth header + iframe for *port*, with a shareable-link card above - when *cloudflare_url* is set. Falls back to serve_kernel_port_as_iframe.""" - url = get_colab_url(port) - logger.info(f"🌐 Unsloth Studio URL: {url}") - if cloudflare_url: - logger.info(f"🔗 Shareable Cloudflare link: {cloudflare_url}") +# Height for serve_kernel_port_as_iframe (~82vh on a 1080p screen, clamped). +_COLAB_IFRAME_HEIGHT = 900 + +def _embed_kernel_port_iframe(port: int) -> bool: + """Embed Studio via Colab's native kernel-port iframe helper. + + Only trusted on a real Colab runtime: colabtools can import ``google.colab`` and + queue browser-side JS without appending an iframe, so callers outside Colab must use + the HTML iframe path instead. + """ + if not _is_colab_runtime(): + return False + try: + from google.colab import output as colab_output + except ImportError: + return False + try: + colab_output.serve_kernel_port_as_iframe( + port, + height = _COLAB_IFRAME_HEIGHT, + width = "100%", + ) + return True + except Exception as e: + logger.info(f"serve_kernel_port_as_iframe failed ({e}); trying HTML iframe.") + return False + + +def _embed_html_iframe(url: str, port: int) -> bool: + """Fallback embed: raw HTML iframe when the Colab helper is unavailable.""" try: from IPython.display import HTML, display + except ImportError: + return False - iframe_id = f"unsloth-studio-{port}" - - # Truncated header URL — best-effort, falls back to full URL. - try: - port_prefix = f"{port}-" - idx = url.index(port_prefix) - next_dash = url.index("-", idx + len(port_prefix)) - short_url = url[: next_dash + 1] + "..." - except (ValueError, IndexError): - short_url = url - - if cloudflare_url: - display(HTML(_shareable_link_html(cloudflare_url))) - + short_url = _short_colab_url(url, port) + iframe_id = f"unsloth-studio-{port}" + try: display( HTML(f"""
""") ) - except Exception: - # Fallback: Colab's built-in helper. + return True + except Exception as e: + logger.info(f"HTML iframe embed failed ({e}).") + return False + + +def _show_and_embed( + port: int, + *, + cloudflare_url: "str | None" = None, + colab_login: "tuple[str, str] | None" = None, + cloudflare_requested: bool = False, +): + """Render the Unsloth ready card + iframe for *port*. + + Prefer Colab's ``serve_kernel_port_as_iframe`` on real Colab; raw HTML iframe is the + fallback. Cloudflare cards stay clickable. + """ + url = get_colab_url(port) + logger.info(f"🌐 Unsloth Studio URL: {url}") + if cloudflare_url: + logger.info(f"🔗 Shareable Cloudflare link: {cloudflare_url}") + + _warn_colab_cloudflare_missing( + use_cloudflare = cloudflare_requested, + cloudflare_url = cloudflare_url, + ) + + if cloudflare_url: try: - from google.colab import output as colab_output - colab_output.serve_kernel_port_as_iframe(port, height = 900, width = "100%") - except ImportError: - pass + from IPython.display import HTML, display + display(HTML(_shareable_link_html(cloudflare_url))) + except Exception as e: + logger.info(f"Could not render Cloudflare link card ({e}).") + + if colab_login: + try: + _show_colab_login_credentials(*colab_login) + except Exception as e: + logger.info(f"Could not render Colab login card ({e}).") + + try: + show_link( + port, + _url = url, + has_cloudflare_link = bool(cloudflare_url), + cloudflare_requested = cloudflare_requested, + ) + except Exception as e: + logger.info(f"Could not render Unsloth link card ({e}).") + + # On Colab with a working tunnel, skip the in-cell proxy embed (often blank). + if _is_colab_runtime() and cloudflare_url: + return + + # Real Colab: kernel helper needs only the port (works when eval_js failed). + if _is_colab_runtime(): + if _embed_kernel_port_iframe(port): + return + _embed_html_iframe(url, port) -def start(port: int = 8888, *, cloudflare: bool = False): +def start(port: int = 8888, *, cloudflare: "bool | None" = None): """Start Unsloth Studio in Colab and display the URL. Args: port: Port to bind/serve on. - cloudflare: Opt in to a shareable Cloudflare HTTPS link reachable from any - device (default OFF). It exposes Unsloth's login page beyond Colab, so it - stays an explicit opt-in; the default shows only the in-tab proxy iframe. + cloudflare: Shareable Cloudflare HTTPS link. ``None`` (default) auto-enables on + real Colab because the in-cell proxy embed is often blank; pass ``False`` to + skip the tunnel or ``True`` to force it on other runtimes. Usage: - start() # Colab-proxy iframe only (default) - start(cloudflare=True) # also open a shareable Cloudflare link + start() # Cloudflare link on Colab (auto); proxy iframe elsewhere + start(cloudflare=False) # Colab proxy iframe only (often blank on current Colab) + start(cloudflare=True) # force Cloudflare link on any runtime """ import time logger.info("🦥 Starting Unsloth Studio...") + use_cloudflare = _colab_wants_cloudflare(cloudflare) - # Fast path: Unsloth already running (cell re-run). Re-launching would collide on - # the port, so just re-show the link and iframe. + # Fast path: already running (cell re-run); re-show link/iframe instead of rebinding the port. if _is_studio_healthy(port): logger.info(f" Unsloth is already running on port {port} — reusing existing server.") # try/finally: tear the tunnel down even if interrupted mid-start/render. try: - cf_url = start_cloudflare_tunnel(port) if cloudflare else None + colab_login = _finalize_colab_admin_password() if use_cloudflare else None + cf_url = start_cloudflare_tunnel(port) if use_cloudflare else None _publish_cloudflare_url(cf_url) - _show_and_embed(port, cloudflare_url = cf_url) + _show_and_embed( + port, + cloudflare_url = cf_url, + colab_login = colab_login, + cloudflare_requested = use_cloudflare, + ) for _ in range(10000): time.sleep(300) print("=", end = "", flush = True) @@ -313,7 +634,6 @@ def start(port: int = 8888, *, cloudflare: bool = False): logger.info(" Loading backend...") from run import run_server - # Auto-detect frontend path repo_root = Path(__file__).parent.parent frontend_path = repo_root / "frontend" / "dist" @@ -323,8 +643,7 @@ def start(port: int = 8888, *, cloudflare: bool = False): logger.info(" Starting server...") try: - # cloudflare=False: this helper owns the tunnel (Colab's own - # start(cloudflare=...) drives it), so pin it off explicitly. + # cloudflare=False: this helper owns the tunnel (via start(cloudflare=...)), so pin it off. app = run_server( host = "0.0.0.0", port = port, @@ -339,14 +658,12 @@ def start(port: int = 8888, *, cloudflare: bool = False): logger.error(f"❌ Unsloth Studio failed to start: {exc}") return - # run_server auto-increments the port if in use; read back the bound port so the - # proxy URL and iframe point at the right place. + # run_server may auto-increment the port; read back the bound port for the proxy URL/iframe. actual_port: int = getattr(getattr(app, "state", None), "server_port", None) or port logger.info(f" Server started on port {actual_port}!") - # Poll health endpoint before showing the link — avoids the race where ready_event - # fires but the process hasn't finished binding. + # Poll health before showing the link: avoids the race where ready_event fires pre-bind. import urllib.request server_ready = False @@ -365,12 +682,17 @@ def start(port: int = 8888, *, cloudflare: bool = False): ) return - # Open the tunnel now the server is healthy, publish its URL for /api/health, and - # tear it down on interrupt (try/finally) rather than orphan the process. + # Server healthy: finalize Colab auth, open the tunnel, publish URL, tear down on interrupt. try: - cf_url = start_cloudflare_tunnel(actual_port) if cloudflare else None + colab_login = _finalize_colab_admin_password() if use_cloudflare else None + cf_url = start_cloudflare_tunnel(actual_port) if use_cloudflare else None _publish_cloudflare_url(cf_url) - _show_and_embed(actual_port, cloudflare_url = cf_url) + _show_and_embed( + actual_port, + cloudflare_url = cf_url, + colab_login = colab_login, + cloudflare_requested = use_cloudflare, + ) # Keep kernel alive so the daemon server thread runs. for _ in range(10000): diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index c8be50b08b..1b24c46e65 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -1048,6 +1048,21 @@ class ExportBackend: "Use the safetensors adapter instead.", None, ) + # llama.cpp's convert_lora_to_gguf.py has no concept of DoRA's + # lora_magnitude_vector tensors: it only reads the standard + # lora_A/lora_B delta, so exporting a DoRA adapter would silently + # drop the magnitude rescaling and produce a GGUF LoRA file that + # loads fine but no longer matches the trained model. + _peft_config = getattr(self.current_model, "peft_config", {}).get("default") + if getattr(_peft_config, "use_dora", False): + return ( + False, + "GGUF LoRA export is not supported for DoRA adapters: the GGUF LoRA " + "format has no way to represent DoRA's magnitude vectors, so the " + "exported file would silently lose the DoRA behavior. Use the " + "safetensors adapter instead, or merge to a full GGUF model.", + None, + ) outtype = str(gguf_outtype).lower() if outtype not in _GGUF_LORA_OUTTYPES: return ( diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9648fb0218..c3599beb7e 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -307,6 +307,26 @@ def _native_linux_system_rocm_lib_dirs(binary_dir: str = "") -> "list[str]": _DEFAULT_MAX_TOKENS_FLOOR = 32768 _DEFAULT_FIRST_TOKEN_TIMEOUT_S = 1200.0 # 20 min + +def _finalize_reasoning_only_cumulative( + cumulative: str, reasoning_text: str, finish_reason: Optional[str], promote_reasoning_only: bool +) -> str: + """Close a live thinking block and promote it only after a clean stop. + + Local inference streams cumulative snapshots. Replacing ``...`` with + bare reasoning at EOF makes the final snapshot shorter, so suffix-based + route consumers drop the intended fallback. Keep the snapshot append-only. + A length-truncated thought is not a final answer, so close it without + promotion and let the client surface the ``length`` terminal state. Raw + consumers that do not split reasoning from visible content can disable the + fallback to avoid returning the same reasoning twice. + """ + visible_fallback = ( + reasoning_text if promote_reasoning_only and finish_reason != "length" else "" + ) + return cumulative + "" + visible_fallback + + # Only large streamed tool payloads get an early provisional card; render_html # is exempt because it needs immediate artifact feedback. _PROVISIONAL_ARGS_MIN_CHARS = 256 @@ -2781,6 +2801,7 @@ class LlamaCppBackend: "found": False, "mtp_token": None, "supports_mtp": False, + "mtp_probe_inconclusive": True, "ngram_mod_flavor": None, "supports_ngram_mod": False, "spec_draft_n_max_flag": None, @@ -2813,6 +2834,9 @@ class LlamaCppBackend: supports_no_cache_prompt = False supports_metrics = False supports_slot_save = False + saw_spec_type = False + probe_ok = False + help_text = "" try: probe_env = cls._llama_server_env_for_binary(bin_path) result = subprocess.run( @@ -2824,6 +2848,7 @@ class LlamaCppBackend: check = False, env = probe_env, ) + probe_ok = result.returncode == 0 help_text = (result.stdout or "") + "\n" + (result.stderr or "") # Split into per-flag blocks (each --flag line + its indented # continuation), so the "argument has been removed" description @@ -2868,17 +2893,19 @@ class LlamaCppBackend: return False return "argument has been removed" not in desc - # MTP token from the --spec-type line. - spec_line = "" - for line in help_text.splitlines(): - if "--spec-type" in line: - spec_line = line - break - # PR #22673 used draft-mtp; later renamed to mtp. - if "draft-mtp" in spec_line: - mtp_token = "draft-mtp" - elif re.search(r"[|,\[]mtp[|,\]]", spec_line): - mtp_token = "mtp" + # MTP token from the full --spec-type help block (decl + indented + # continuation). First-line-only probing missed builds putting the + # enum on the next line (#7302). Prefer draft-mtp (PR #22673) over mtp. + spec_help = blocks.get("--spec-type") or "" + if not spec_help: + # Fallback: join --spec-type lines, avoiding incidental "mtp" in --help. + spec_help = "\n".join( + line for line in help_text.splitlines() if "--spec-type" in line + ) + mtp_token = cls._mtp_token_from_spec_help(spec_help) + # Only a resolved --spec-type block confirms missing MTP; empty/crash + # leaves saw_spec_type False so supports_mtp fails open. + saw_spec_type = bool(spec_help.strip()) and "--spec-type" in spec_help # ngram-mod flag flavor. Post-rename builds advertise both new # args (real) and legacy ones (stubs); pre-rename builds only @@ -2914,11 +2941,29 @@ class LlamaCppBackend: supports_slot_save = _is_real("--slot-save-path") except (OSError, subprocess.SubprocessError) as exc: logger.debug(f"llama-server --help probe failed: {exc}") + saw_spec_type = False + probe_ok = False + help_text = "" + + help_nonempty = bool(help_text.strip()) + # Confirmed only when a successful --help lists a --spec-type block with + # mtp/draft-mtp; nonempty --help without it is a definitive pre-spec + # binary; failed/empty probes stay inconclusive (#7302). + if saw_spec_type and probe_ok: + supports_mtp = mtp_token is not None + mtp_probe_inconclusive = False + elif help_nonempty and probe_ok: + supports_mtp = False + mtp_probe_inconclusive = False + else: + supports_mtp = False + mtp_probe_inconclusive = True info = { "found": True, "mtp_token": mtp_token, - "supports_mtp": mtp_token is not None, + "supports_mtp": supports_mtp, + "mtp_probe_inconclusive": mtp_probe_inconclusive, "ngram_mod_flavor": ngram_mod_flavor, "supports_ngram_mod": ngram_mod_flavor is not None, "spec_draft_n_max_flag": spec_draft_n_max_flag, @@ -2934,6 +2979,21 @@ class LlamaCppBackend: cls._capability_cache[cache_key] = info return info + @staticmethod + def _mtp_token_from_spec_help(spec_help: str) -> Optional[str]: + """Extract ``draft-mtp`` / ``mtp`` from a ``--spec-type`` help snippet. + + Prefers ``draft-mtp`` (llama.cpp PR #22673) over the later bare ``mtp`` + rename. Returns ``None`` when neither token appears as an enum value. + """ + text = spec_help or "" + if "draft-mtp" in text: + return "draft-mtp" + # Bare `mtp` enum token (`|mtp|`, `,mtp,`, ...), not a substring. + if re.search(r"(? str: + """User-facing error when the text-only --mmproj strip retry also fails. + + Confirmed projector-format mismatches keep the historical wording. + Bare signal crashes (common on some ROCm/driver paths) must not be + reported as "Vision projector incompatible" — that misled #7302. + """ + if projector_confirmed: + return ( + "Vision projector incompatible with this llama.cpp " + "build, and the text-only retry also failed: " + detail + ) + return ( + "Vision model failed to start (llama-server crashed with " + "--mmproj), and the text-only retry also failed: " + detail + ) + @staticmethod def _output_has_nonprojector_diagnostic(output: str) -> bool: """True when the output already names a concrete non-projector cause (out @@ -8453,23 +8531,29 @@ class LlamaCppBackend: self._kill_process() # The #6415 split-axis abort is latched earlier (first spawn). # Skip if a cancel/unload is pending (mirrors the MTP guard). + _projector_msg = self._is_projector_incompatibility(out) + _signal_mmproj_guess = self._is_signal_crash( + _crash_rc + ) and not self._output_has_nonprojector_diagnostic(out) if ( launched_with_mmproj and not self._cancel_event.is_set() - and ( - self._is_projector_incompatibility(out) - or ( - self._is_signal_crash(_crash_rc) - and not self._output_has_nonprojector_diagnostic(out) - ) - ) + and (_projector_msg or _signal_mmproj_guess) ): - logger.warning( - "llama-server could not load this model's vision " - "projector (--mmproj). The installed llama.cpp build is " - "likely too old for it. Loading text-only for this " - "session; run 'unsloth studio update' to enable vision." - ) + if _projector_msg: + logger.warning( + "llama-server could not load this model's vision " + "projector (--mmproj). The installed llama.cpp build is " + "likely too old for it. Loading text-only for this " + "session; run 'unsloth studio update' to enable vision." + ) + else: + logger.warning( + "llama-server crashed while loading this model's vision " + "projector (--mmproj). Retrying text-only for this " + "session; if this persists, run 'unsloth studio update' " + "or check GPU/driver logs." + ) cmd = self._strip_mmproj_args(_last_spawn_cmd) # This retry bypasses _spawn_and_wait, so refresh the # launched-argv snapshot itself -- the zero-offload @@ -8497,14 +8581,16 @@ class LlamaCppBackend: "(e.g. ROCR_VISIBLE_DEVICES=0 exposes only the first " "GPU) before launching Unsloth Studio." ) + _retry_detail = self._classify_llama_start_failure( + "\n".join(self._stdout_lines[-50:]), + gguf_path, + self._model_identifier, + _retry_rc, + ) raise RuntimeError( - "Vision projector incompatible with this llama.cpp " - "build, and the text-only retry also failed: " - + self._classify_llama_start_failure( - "\n".join(self._stdout_lines[-50:]), - gguf_path, - self._model_identifier, - _retry_rc, + self._mmproj_retry_failure_message( + projector_confirmed = _projector_msg, + detail = _retry_detail, ) ) else: @@ -8734,18 +8820,29 @@ class LlamaCppBackend: caps = self.probe_server_capabilities(binary) mtp_token = caps.get("mtp_token") if caps else None if not mtp_token: - logger.warning( - "Requested MTP speculative decoding but " - "llama-server lacks --spec-type mtp/draft-mtp; " - "run `unsloth studio update`. Loading without " - "speculative decoding." - ) + inconclusive = bool(caps.get("mtp_probe_inconclusive")) if caps else True + if inconclusive: + logger.info( + "Requested MTP speculative decoding but llama-server MTP " + "capability probe was inconclusive; loading without " + "speculative decoding." + ) + else: + logger.warning( + "Requested MTP speculative decoding but " + "llama-server lacks --spec-type mtp/draft-mtp; " + "run `unsloth studio update`. Loading without " + "speculative decoding." + ) # Override an inherited LLAMA_ARG_SPEC_TYPE=draft-mtp (CLI wins # over env) so the child matches the binary-capability gate and # the no-MTP budget, like the sibling no-head/non-MTP fallbacks. flags.append("--spec-default") self._speculative_type = "default" - self._spec_fallback_reason = "binary_no_mtp" + if inconclusive: + self._spec_fallback_reason = None + else: + self._spec_fallback_reason = "binary_no_mtp" return False draft_n_max = _resolved_draft_n_max() n_max_flag = caps.get("spec_draft_n_max_flag") or "--spec-draft-n-max" @@ -10544,6 +10641,7 @@ class LlamaCppBackend: reasoning_effort: Optional[str] = None, preserve_thinking: Optional[bool] = None, seed: Optional[int] = None, + promote_reasoning_only: bool = True, _allow_respawn_retry: bool = True, ) -> Generator[Union[str, dict], None, None]: """ @@ -10626,7 +10724,12 @@ class LlamaCppBackend: # model put its whole reply in reasoning # (e.g. Qwen3 always-think). Show it as # the main response, not a thinking block. - cumulative = reasoning_text + cumulative = _finalize_reasoning_only_cumulative( + cumulative, + reasoning_text, + _metadata_finish_reason, + promote_reasoning_only, + ) yield cumulative _stream_done = True break # exit inner while @@ -10723,6 +10826,7 @@ class LlamaCppBackend: reasoning_effort = reasoning_effort, preserve_thinking = preserve_thinking, seed = seed, + promote_reasoning_only = promote_reasoning_only, _allow_respawn_retry = False, ) return @@ -10764,6 +10868,7 @@ class LlamaCppBackend: confirm_tool_calls: bool = False, bypass_permissions: bool = False, permission_mode: Optional[str] = None, + promote_reasoning_only: bool = True, ) -> Generator[dict, None, None]: """ Agentic loop: let the model call tools, execute them, and continue. @@ -11106,7 +11211,12 @@ class LlamaCppBackend: ), } else: - cumulative_display = reasoning_accum + cumulative_display = _finalize_reasoning_only_cumulative( + cumulative_display, + reasoning_accum, + _iter_finish_reason, + promote_reasoning_only, + ) if not _suppress_visible_output: yield { "type": "content", @@ -11570,7 +11680,12 @@ class LlamaCppBackend: if _reasoning_started_at is not None and not _reasoning_summary_emitted: _reasoning_summary_emitted = True yield _reasoning_summary_event(_reasoning_started_at) - cumulative_display = reasoning_accum + cumulative_display = _finalize_reasoning_only_cumulative( + cumulative_display, + reasoning_accum, + _iter_finish_reason, + promote_reasoning_only, + ) if not _suppress_visible_output: yield { "type": "content", @@ -12134,7 +12249,12 @@ class LlamaCppBackend: "text": _strip_tool_markup(cumulative, final = True), } else: - cumulative = reasoning_text + cumulative = _finalize_reasoning_only_cumulative( + cumulative, + reasoning_text, + _metadata_finish_reason, + promote_reasoning_only, + ) yield {"type": "content", "text": cumulative} _stream_done = True break # exit inner while diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 53cba522ed..b858fe6f17 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -891,6 +891,7 @@ class UnslothTrainer: use_gradient_checkpointing: str = "unsloth", use_rslora: bool = False, use_loftq: bool = False, + use_dora: bool = False, modules_to_save: list = None, ) -> bool: """ @@ -993,6 +994,7 @@ class UnslothTrainer: use_gradient_checkpointing = use_gradient_checkpointing, random_state = 3407, use_rslora = use_rslora, + use_dora = use_dora, loftq_config = {"loftq_bits": 4, "loftq_iter": 1} if use_loftq else None, ) # Audio VLM models support VLM-style layer selection @@ -1023,6 +1025,7 @@ class UnslothTrainer: use_gradient_checkpointing = use_gradient_checkpointing, random_state = 3407, use_rslora = use_rslora, + use_dora = use_dora, loftq_config = {"loftq_bits": 4, "loftq_iter": 1} if use_loftq else None, task_type = None, ) @@ -1042,6 +1045,7 @@ class UnslothTrainer: use_gradient_checkpointing = use_gradient_checkpointing, random_state = 3407, use_rslora = use_rslora, + use_dora = use_dora, loftq_config = {"loftq_bits": 4, "loftq_iter": 1} if use_loftq else None, ) @@ -1067,6 +1071,7 @@ class UnslothTrainer: use_gradient_checkpointing = use_gradient_checkpointing, random_state = 3407, use_rslora = use_rslora, + use_dora = use_dora, loftq_config = {"loftq_bits": 4, "loftq_iter": 1} if use_loftq else None, modules_to_save = modules_to_save, ) @@ -1087,6 +1092,7 @@ class UnslothTrainer: use_gradient_checkpointing = use_gradient_checkpointing, random_state = 3407, use_rslora = use_rslora, + use_dora = use_dora, loftq_config = {"loftq_bits": 4, "loftq_iter": 1} if use_loftq else None, modules_to_save = modules_to_save, ) diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index b87585ffe3..8592dabbfe 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -196,6 +196,7 @@ def _build_training_worker_config(values: dict[str, Any]) -> dict[str, Any]: "gradient_checkpointing": values.get("gradient_checkpointing", "unsloth"), "use_rslora": values.get("use_rslora", False), "use_loftq": values.get("use_loftq", False), + "use_dora": values.get("use_dora", False), "train_on_completions": values.get("train_on_completions", False), "finetune_vision_layers": values.get("finetune_vision_layers", True), "finetune_language_layers": values.get("finetune_language_layers", True), @@ -455,6 +456,7 @@ class _MLXTrainerAdapter: use_gradient_checkpointing: Union[str, bool] = "unsloth", use_rslora: bool = False, use_loftq: bool = False, + use_dora: bool = False, ) -> bool: self._peft_config = { "use_lora": bool(use_lora), @@ -465,6 +467,7 @@ class _MLXTrainerAdapter: "gradient_checkpointing": use_gradient_checkpointing, "use_rslora": bool(use_rslora), "use_loftq": bool(use_loftq), + "use_dora": bool(use_dora), "finetune_vision_layers": bool(finetune_vision_layers), "finetune_language_layers": bool(finetune_language_layers), "finetune_attention_modules": bool(finetune_attention_modules), @@ -572,6 +575,7 @@ class _MLXTrainerAdapter: "gradient_checkpointing": "unsloth", "use_rslora": False, "use_loftq": False, + "use_dora": False, "finetune_vision_layers": True, "finetune_language_layers": True, "finetune_attention_modules": True, diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 81010fb5df..03327d3320 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -1547,6 +1547,10 @@ def _run_mlx_training(event_queue, stop_queue, config): message = "LoftQ is not supported for MLX training yet." _send("error", error = message) raise NotImplementedError(message) + if config.get("use_dora"): + message = "DoRA is not supported for MLX training yet." + _send("error", error = message) + raise NotImplementedError(message) if config.get("is_embedding"): message = "Embedding model training is not supported for MLX training yet." _send("error", error = message) @@ -3186,6 +3190,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> use_gradient_checkpointing = config.get("gradient_checkpointing", "unsloth"), use_rslora = config.get("use_rslora", False), use_loftq = config.get("use_loftq", False), + use_dora = config.get("use_dora", False), ) elif use_lora: _send_status(event_queue, "Configuring LoRA adapters...") @@ -3202,6 +3207,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> use_gradient_checkpointing = config.get("gradient_checkpointing", "unsloth"), use_rslora = config.get("use_rslora", False), use_loftq = config.get("use_loftq", False), + use_dora = config.get("use_dora", False), ) else: _send_status(event_queue, "Preparing model for full finetuning...") @@ -3630,6 +3636,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) -> use_gradient_checkpointing = gradient_checkpointing, random_state = config.get("random_seed", 3407), use_rslora = config.get("use_rslora", False), + use_dora = config.get("use_dora", False), loftq_config = {"loftq_bits": 4, "loftq_iter": 1} if config.get("use_loftq") else None, diff --git a/studio/backend/main.py b/studio/backend/main.py index 1f5d02dc54..6490cc5a9e 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -445,7 +445,11 @@ def _run_llama_cpp_startup_probes(app: FastAPI) -> None: import structlog as _structlog _log = _structlog.get_logger(__name__) - if _caps.get("found") and not _caps.get("supports_mtp"): + if ( + _caps.get("found") + and not _caps.get("supports_mtp") + and not _caps.get("mtp_probe_inconclusive") + ): _msg = ( "llama.cpp prebuilt lacks MTP support " "(--spec-type mtp/draft-mtp). Run `unsloth studio update`. " diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 2663242187..1758efe515 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -70,7 +70,10 @@ class LoadRequest(BaseModel): cache_type_kv: Optional[str] = Field( None, - description = "KV cache data type for both K and V (e.g. 'f16', 'bf16', 'q8_0', 'q4_1', 'q5_1')", + description = ( + "KV cache data type for both K and V " + "(e.g. 'f16', 'bf16', 'q8_0', 'q4_0', 'q4_1', 'q5_0', 'q5_1', 'iq4_nl', 'f32')" + ), ) gpu_ids: Optional[List[int]] = Field( None, @@ -442,7 +445,10 @@ class LoadResponse(BaseModel): ) cache_type_kv: Optional[str] = Field( None, - description = "KV cache data type for K and V (e.g. 'f16', 'bf16', 'q8_0')", + description = ( + "KV cache data type for K and V " + "(e.g. 'f16', 'bf16', 'q8_0', 'q4_0', 'q4_1', 'q5_0', 'q5_1', 'iq4_nl', 'f32')" + ), ) chat_template: Optional[str] = Field( None, @@ -602,7 +608,11 @@ class InferenceStatusResponse(BaseModel): ) cache_type_kv: Optional[str] = Field( None, - description = "KV cache quantization dtype (e.g. 'q8_0'), or None for default", + description = ( + "KV cache quantization dtype " + "(e.g. 'f16', 'bf16', 'q8_0', 'q4_0', 'q4_1', 'q5_0', 'q5_1', 'iq4_nl', 'f32'), " + "or None for default" + ), ) chat_template: Optional[str] = Field( None, description = "Model's default chat template (Jinja2 source), if any" diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 6416b86069..0aca5da72c 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -470,6 +470,7 @@ class TrainingStartRequest(BaseModel): gradient_checkpointing: str = Field("", description = "Gradient checkpointing setting") use_rslora: bool = Field(False, description = "Use RSLoRA") use_loftq: bool = Field(False, description = "Use LoftQ") + use_dora: bool = Field(False, description = "Use DoRA") train_on_completions: bool = Field(False, description = "Train on completions only") # Vision-specific LoRA parameters @@ -545,6 +546,37 @@ class TrainingStartRequest(BaseModel): raise ValueError("Either num_epochs or max_steps must be > 0; both cannot be 0.") return self + @model_validator(mode = "after") + def _validate_lora_variant_flags(self) -> "TrainingStartRequest": + # The frontend only ever sends one of these and never under Full + # Finetuning, but a direct API/YAML/CLI caller can bypass that. Nothing + # downstream breaks (full finetune ignores them, MLX rejects use_dora/ + # use_loftq outright), but reject early here for a clear error instead + # of a silently-ignored flag. + active = [ + name + for name, enabled in ( + ("use_rslora", self.use_rslora), + ("use_loftq", self.use_loftq), + ("use_dora", self.use_dora), + ) + if enabled + ] + if len(active) > 1: + raise ValueError( + f"Only one LoRA variant may be enabled at a time; got {active}. " + "use_rslora, use_loftq, and use_dora are mutually exclusive." + ) + # getattr, not self.training_type: model_construct() (used by tests that + # validate a single field in isolation) leaves required fields unset, and + # this is a mode="after" validator so it still runs on that partial instance. + if getattr(self, "training_type", None) == "Full Finetuning" and active: + raise ValueError( + f"{active[0]} requires an adapter method (LoRA/QLoRA or " + "Continued Pretraining); it has no effect under Full Finetuning." + ) + return self + class TrainingJobResponse(BaseModel): """Immediate response when training is initiated""" diff --git a/studio/backend/routes/chat_history.py b/studio/backend/routes/chat_history.py index 24b6dfb36d..6a0d49b47d 100644 --- a/studio/backend/routes/chat_history.py +++ b/studio/backend/routes/chat_history.py @@ -160,11 +160,26 @@ class ChatInferenceSettings(BaseModel): fastMode: Optional[bool] = None +class ChatPresetLoadConfig(BaseModel): + model_config = ConfigDict(extra = "forbid") + + customContextLength: Optional[int] = Field(default = None, gt = 0) + maxSeqLength: Optional[float] = None + kvCacheDtype: Optional[str] = None + speculativeType: Optional[str] = None + specDraftNMax: Optional[int] = Field(default = None, ge = 1, le = 16) + tensorParallel: Optional[bool] = None + gpuMemoryMode: Optional[Literal["manual"]] = None + gpuLayers: Optional[int] = None + nCpuMoe: Optional[int] = Field(default = None, ge = 0) + + class ChatPreset(BaseModel): model_config = ConfigDict(extra = "forbid") name: str params: ChatInferenceSettings + loadConfig: Optional[ChatPresetLoadConfig] = None class ChatSettingsPayload(BaseModel): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 04edfcd9d5..598412ad65 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1794,7 +1794,16 @@ router = APIRouter() studio_router = APIRouter() -_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost" +# Packaged desktop runs at tauri://localhost (macOS/Linux) or http://tauri.localhost +# (Windows WebView2); the web build is same-origin ('self'). The `tauri dev` shell, +# however, serves the frontend from the Vite dev origin (http://localhost:5173), +# so the packaged allowlist alone leaves the preview blocked in dev with an +# "ancestor violates frame-ancestors" error. This shell exposes no server resource +# (it only renders postMessage'd HTML in a no-same-origin sandbox), so also allowing +# any localhost/127.0.0.1 dev origin to frame it is safe and unblocks the dev shell. +_ARTIFACT_PREVIEW_FRAME_ANCESTORS = ( + "'self' tauri://localhost http://tauri.localhost http://localhost:* http://127.0.0.1:*" +) _ARTIFACT_PREVIEW_FRAME_STRICT_CSP = ( "default-src 'none'; " "script-src 'unsafe-inline'; " @@ -5843,10 +5852,15 @@ async def get_status(current_subject: str = Depends(get_current_subject)): try: _bin = type(llama_backend)._find_llama_server_binary() _caps = type(llama_backend).probe_server_capabilities(_bin) - _supports_mtp = bool(_caps.get("supports_mtp", False)) + # Fail open on inconclusive probes: False means a definitive + # "binary lacks MTP" to API consumers. + _supports_mtp = bool( + _caps.get("supports_mtp", False) + or (_caps.get("found", False) and _caps.get("mtp_probe_inconclusive", False)) + ) except Exception: _bin = None - _supports_mtp = True # fail open + _supports_mtp = False # no usable binary: MTP genuinely unavailable try: from utils.llama_cpp_freshness import check_prebuilt_freshness _freshness = check_prebuilt_freshness(_bin) @@ -13358,6 +13372,7 @@ async def anthropic_messages( disable_parallel_tool_use = _disable_parallel, bypass_permissions = bool(payload.bypass_permissions), permission_mode = getattr(payload, "permission_mode", None), + promote_reasoning_only = False, ) if payload.stream: @@ -13397,6 +13412,7 @@ async def anthropic_messages( max_tokens = payload.max_tokens, stop = stop, cancel_event = cancel_event, + promote_reasoning_only = False, ) if payload.stream: diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 3c0d6ff4ba..ed83a12f48 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -2692,7 +2692,10 @@ async def get_kv_cache_estimate( repo_id: str = Query(..., description = "HF repo ID or local path"), quant: str = Query(..., description = "Quantization label (e.g. Q4_K_M)"), n_ctx: int = Query(..., ge = 1, description = "Context length to size the KV cache for"), - cache_type_kv: Optional[str] = Query(None, description = "KV cache dtype (e.g. q8_0)"), + cache_type_kv: Optional[str] = Query( + None, + description = "KV cache dtype (e.g. q8_0, q4_0, q5_0, iq4_nl, f32)", + ), current_subject: str = Depends(get_current_subject), ): """Estimate KV cache + weight bytes for a downloaded GGUF at n_ctx. diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index 5b10652fdd..8be4283415 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -332,6 +332,7 @@ async def start_training( else "unsloth", "use_rslora": request.use_rslora, "use_loftq": request.use_loftq, + "use_dora": request.use_dora, "train_on_completions": request.train_on_completions, "finetune_vision_layers": request.finetune_vision_layers, "finetune_language_layers": request.finetune_language_layers, diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 9ccc3f44dd..621ac9aaca 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -68,16 +68,15 @@ def _emitter_client_text(events: list[str]) -> str: def test_anthropic_emitter_closes_reasoning_only_think_block(): - # A reasoning-only reply streams X live then shrinks to bare X at EOF. - # This emitter diffs cumulative snapshots and drops the shrink, so without a - # closing pass the client text would end on an unclosed . finish() - # must balance it. + # Anthropic asks the GGUF generator not to promote reasoning into a duplicate + # visible fallback, so its final cumulative snapshot only balances the block. emitter = AnthropicStreamEmitter() events = emitter.start("msg_1", "m") events += emitter.feed({"type": "content", "text": "The capital"}) events += emitter.feed({"type": "content", "text": "The capital of France is Paris."}) - # The generator's final bare-text shrink (dropped by the cumulative diff). - events += emitter.feed({"type": "content", "text": "The capital of France is Paris."}) + events += emitter.feed( + {"type": "content", "text": "The capital of France is Paris."} + ) events += emitter.finish() assert _emitter_client_text(events) == "The capital of France is Paris." @@ -1563,6 +1562,44 @@ class TestAnthropicMessagesToolRouting: assert entry["context_length"] == 2048 assert monitor.active_count() == 0 + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("with_tools", [False, True]) + def test_reasoning_only_output_is_not_duplicated(self, monkeypatch, stream, with_tools): + reasoning = "The capital of France is Paris." + + def _gen_plain(**kwargs): + assert kwargs["promote_reasoning_only"] is False + yield f"{reasoning}" + yield f"{reasoning}" + + def _gen_tools(**kwargs): + assert kwargs["promote_reasoning_only"] is False + yield {"type": "content", "text": f"{reasoning}"} + yield {"type": "content", "text": f"{reasoning}"} + + _mock_backend( + monkeypatch, + generate_chat_completion = _gen_plain, + generate_chat_completion_with_tools = _gen_tools, + ) + payload_fields = {"stream": stream} + if with_tools: + payload_fields.update( + { + "enable_tools": True, + "tools": [{"type": "web_search_20250305", "name": "web_search"}], + } + ) + payload = _basic_payload(**payload_fields) + + response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t")) + if stream: + body = self._sse_blob(self._consume_response(response)) + assert body.count(reasoning) == 1 + else: + body = json.loads(response.body) + assert body["content"][0]["text"] == f"{reasoning}" + def test_tool_use_non_streaming_records_api_monitor_reply(self, monkeypatch): import routes.inference as inf_mod diff --git a/studio/backend/tests/test_chat_history_routes.py b/studio/backend/tests/test_chat_history_routes.py index a60ac700bf..896bf1a6cd 100644 --- a/studio/backend/tests/test_chat_history_routes.py +++ b/studio/backend/tests/test_chat_history_routes.py @@ -91,6 +91,28 @@ def test_chat_settings_payload_accepts_fast_mode_presets(): assert dumped["customPresets"][0]["params"]["fastMode"] is True +def test_chat_settings_payload_accepts_preset_load_config(): + payload = chat_history.ChatSettingsPayload.model_validate( + { + "customPresets": [ + { + "name": "GGUF preset", + "params": {"temperature": 0.7, "maxTokens": 512}, + "loadConfig": { + "customContextLength": 256, + "kvCacheDtype": "q8_0", + "tensorParallel": False, + }, + }, + ], + } + ) + + dumped = payload.model_dump(exclude_unset = True) + assert dumped["customPresets"][0]["loadConfig"]["customContextLength"] == 256 + assert dumped["customPresets"][0]["loadConfig"]["kvCacheDtype"] == "q8_0" + + def test_chat_settings_payload_accepts_nudge_tool_calls(): # extra="forbid" 400s PUT /api/chat/settings on unknown keys, so the # frontend's persisted nudgeToolCalls needs a payload field (like diff --git a/studio/backend/tests/test_colab_embed.py b/studio/backend/tests/test_colab_embed.py new file mode 100644 index 0000000000..dae0c7dae0 --- /dev/null +++ b/studio/backend/tests/test_colab_embed.py @@ -0,0 +1,479 @@ +# 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 coverage for Colab iframe embedding (#7344).""" + +import sys +import types +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import colab + + +def _mock_google_colab_modules(colab_mod): + """Mock ``google`` and ``google.colab`` for environments without Google packages.""" + google_mod = types.ModuleType("google") + google_mod.colab = colab_mod + return {"google": google_mod, "google.colab": colab_mod} + + +def test_short_colab_url_truncates_proxy_host(): + url = "https://8888-gpu-a100-s-kkb-usc1f0-9hzedjcxrlu8-f.us-central1-0.prod.colab.dev/" + assert colab._short_colab_url(url, 8888) == "https://8888-gpu-..." + + +def test_short_colab_url_falls_back_on_unexpected_shape(): + assert colab._short_colab_url("https://example.com", 8888) == "https://example.com" + + +def test_is_colab_proxy_url_requires_https_proxy(): + assert colab._is_colab_proxy_url("https://8888-test.prod.colab.dev/", 8888) is True + assert colab._is_colab_proxy_url("http://localhost:8888", 8888) is False + assert colab._is_colab_proxy_url("http://127.0.0.1:8888", 8888) is False + + +def test_ready_card_html_does_not_open_colab_proxy_in_new_tab(): + """Colab proxy hosts 404 as top-level tabs (#7349 reporter); never window.open them.""" + html = colab._ready_card_html("https://8888-test.prod.colab.dev/", 8888) + assert "window.open" not in html + assert 'href="https://8888-test.prod.colab.dev/"' not in html + assert "start(cloudflare=True)" in html + + +def test_ready_card_html_points_to_cloudflare_when_link_ready(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + html = colab._ready_card_html( + "https://8888-test.prod.colab.dev/", + 8888, + has_cloudflare_link = True, + ) + assert "Cloudflare link above" in html + + +def test_ready_card_html_warns_when_cloudflare_tunnel_missing(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + html = colab._ready_card_html( + "https://8888-test.prod.colab.dev/", + 8888, + cloudflare_requested = True, + ) + assert "Could not open a Cloudflare tunnel" in html + + +def test_warn_colab_cloudflare_missing_logs_on_colab_without_tunnel(monkeypatch): + warnings: list[str] = [] + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr(colab.logger, "warning", lambda msg, **kwargs: warnings.append(msg)) + colab._warn_colab_cloudflare_missing(use_cloudflare = True, cloudflare_url = None) + assert warnings + assert "Cloudflare tunnel unavailable" in warnings[0] + + +def test_warn_colab_cloudflare_missing_skips_when_tunnel_ready(monkeypatch, caplog): + import logging + + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + with caplog.at_level(logging.WARNING): + colab._warn_colab_cloudflare_missing( + use_cloudflare = True, + cloudflare_url = "https://share.trycloudflare.com", + ) + assert "Cloudflare tunnel unavailable" not in caplog.text + + +def test_is_colab_runtime_uses_backend_colab_detector(monkeypatch): + fake_main = types.ModuleType("main") + fake_main._IS_COLAB = True + monkeypatch.setitem(sys.modules, "main", fake_main) + assert colab._is_colab_runtime() is True + fake_main._IS_COLAB = False + assert colab._is_colab_runtime() is False + + +def test_ready_card_html_uses_cloudflare_hint_on_colab_runtime_localhost(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + html = colab._ready_card_html("http://localhost:8888", 8888) + assert "window.open" not in html + assert "start(cloudflare=True)" in html + + +def test_ready_card_html_keeps_open_button_for_localhost_outside_colab(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: False) + html = colab._ready_card_html("http://localhost:8888", 8888) + assert "window.open" in html + assert 'href="http://localhost:8888"' in html + assert "Open Unsloth Studio" in html + + +def test_embed_kernel_port_iframe_uses_colab_helper(monkeypatch): + colab_output = MagicMock() + google_colab = SimpleNamespace(output = colab_output) + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + with patch.dict("sys.modules", _mock_google_colab_modules(google_colab)): + assert colab._embed_kernel_port_iframe(8888) is True + colab_output.serve_kernel_port_as_iframe.assert_called_once_with( + 8888, + height = colab._COLAB_IFRAME_HEIGHT, + width = "100%", + ) + + +def test_embed_kernel_port_iframe_returns_false_without_colab(): + with patch.dict("sys.modules", _mock_google_colab_modules(None)): + assert colab._embed_kernel_port_iframe(8888) is False + + +def test_embed_kernel_port_iframe_skips_colabtools_without_runtime(monkeypatch): + """colabtools can queue JS without appending an iframe; only trust the helper on Colab.""" + colab_output = MagicMock() + google_colab = SimpleNamespace(output = colab_output) + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: False) + with patch.dict("sys.modules", _mock_google_colab_modules(google_colab)): + assert colab._embed_kernel_port_iframe(8888) is False + colab_output.serve_kernel_port_as_iframe.assert_not_called() + + +def test_show_and_embed_prefers_kernel_port_iframe(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(colab, "get_colab_url", lambda port: f"https://{port}-test.prod.colab.dev/") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "show_link", + lambda port, + *, + _url = None, + has_cloudflare_link = False, + cloudflare_requested = False: calls.append("show_link"), + ) + monkeypatch.setattr( + colab, + "_embed_kernel_port_iframe", + lambda port: calls.append("kernel_iframe") or True, + ) + monkeypatch.setattr( + colab, + "_embed_html_iframe", + lambda url, port: calls.append("html_iframe") or True, + ) + + colab._show_and_embed(8888) + + assert calls == ["show_link", "kernel_iframe"] + + +def test_show_and_embed_falls_back_to_html_iframe(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(colab, "get_colab_url", lambda port: f"https://{port}-test.prod.colab.dev/") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: False) + monkeypatch.setattr( + colab, + "show_link", + lambda port, *, _url = None, has_cloudflare_link = False: None, + ) + monkeypatch.setattr(colab, "_embed_kernel_port_iframe", lambda port: False) + monkeypatch.setattr( + colab, + "_embed_html_iframe", + lambda url, port: calls.append((url, port)) or True, + ) + + colab._show_and_embed(8888) + + assert calls == [("https://8888-test.prod.colab.dev/", 8888)] + + +def test_colab_wants_cloudflare_auto_enables_on_runtime(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + assert colab._colab_wants_cloudflare(None) is True + assert colab._colab_wants_cloudflare(True) is True + assert colab._colab_wants_cloudflare(False) is False + + +def test_colab_wants_cloudflare_defaults_off_outside_runtime(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: False) + assert colab._colab_wants_cloudflare(None) is False + assert colab._colab_wants_cloudflare(True) is True + + +def test_finalize_colab_admin_password_skips_outside_runtime(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: False) + assert colab._finalize_colab_admin_password() is None + + +def test_finalize_colab_admin_password_clears_bootstrap_gate(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr(colab, "_load_colab_login_credentials", lambda: None) + stored: list[tuple[str, str]] = [] + monkeypatch.setattr( + colab, + "_store_colab_login_credentials", + lambda username, password: stored.append((username, password)), + ) + + storage = SimpleNamespace( + DEFAULT_ADMIN_USERNAME = "unsloth", + ensure_default_admin = MagicMock(), + get_bootstrap_password = MagicMock(return_value = "alpha-beta-gamma"), + generate_bootstrap_password = MagicMock(return_value = "alpha-beta-gamma"), + requires_password_change = MagicMock(return_value = True), + update_password = MagicMock(return_value = True), + ) + auth_pkg = types.ModuleType("auth") + auth_pkg.storage = storage + with patch.dict("sys.modules", {"auth": auth_pkg, "auth.storage": storage}): + result = colab._finalize_colab_admin_password() + + assert result == ("unsloth", "alpha-beta-gamma") + storage.ensure_default_admin.assert_called_once() + storage.update_password.assert_called_once_with("unsloth", "alpha-beta-gamma") + assert stored == [("unsloth", "alpha-beta-gamma")] + + +def test_start_skips_finalize_when_cloudflare_disabled(monkeypatch): + import time + + finalize_calls: list[str] = [] + monkeypatch.setattr(colab, "_is_studio_healthy", lambda port: True) + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "_finalize_colab_admin_password", + lambda: finalize_calls.append("finalize") or ("unsloth", "secret"), + ) + monkeypatch.setattr( + colab, "start_cloudflare_tunnel", lambda port: "https://share.trycloudflare.com" + ) + monkeypatch.setattr(colab, "_publish_cloudflare_url", lambda url: None) + monkeypatch.setattr(colab, "_show_and_embed", lambda port, **kwargs: None) + monkeypatch.setattr(colab, "_stop_cloudflare_tunnel", lambda: None) + monkeypatch.setattr(time, "sleep", lambda _: (_ for _ in ()).throw(KeyboardInterrupt)) + + colab.start(cloudflare = False) + + assert finalize_calls == [] + + +def test_finalize_colab_admin_password_redisplay_on_rerun(monkeypatch): + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "_load_colab_login_credentials", + lambda: ("unsloth", "saved-pass"), + ) + monkeypatch.setattr(colab, "_colab_credentials_still_valid", lambda username, password: True) + + storage = SimpleNamespace( + DEFAULT_ADMIN_USERNAME = "unsloth", + ensure_default_admin = MagicMock(), + get_bootstrap_password = MagicMock(), + generate_bootstrap_password = MagicMock(), + requires_password_change = MagicMock(return_value = False), + update_password = MagicMock(), + ) + auth_pkg = types.ModuleType("auth") + auth_pkg.storage = storage + with patch.dict("sys.modules", {"auth": auth_pkg, "auth.storage": storage}): + result = colab._finalize_colab_admin_password() + + assert result == ("unsloth", "saved-pass") + storage.update_password.assert_not_called() + + +def test_finalize_colab_admin_password_drops_stale_cached_credentials(monkeypatch): + """After an in-app password change the cached first-run password no longer + authenticates, so it must not be redisplayed (#7349 Codex review).""" + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "_load_colab_login_credentials", + lambda: ("unsloth", "stale-pass"), + ) + monkeypatch.setattr(colab, "_colab_credentials_still_valid", lambda username, password: False) + cleared: list[bool] = [] + monkeypatch.setattr(colab, "_clear_colab_login_credentials", lambda: cleared.append(True)) + + storage = SimpleNamespace( + DEFAULT_ADMIN_USERNAME = "unsloth", + ensure_default_admin = MagicMock(), + get_bootstrap_password = MagicMock(), + generate_bootstrap_password = MagicMock(), + requires_password_change = MagicMock(return_value = False), + update_password = MagicMock(), + ) + auth_pkg = types.ModuleType("auth") + auth_pkg.storage = storage + with patch.dict("sys.modules", {"auth": auth_pkg, "auth.storage": storage}): + result = colab._finalize_colab_admin_password() + + assert result is None + assert cleared == [True] + storage.update_password.assert_not_called() + + +def test_colab_credentials_still_valid_matches_stored_hash(monkeypatch): + from auth.hashing import hash_password + + salt, pwd_hash = hash_password("right-pass") + storage = SimpleNamespace( + get_user_and_secret = MagicMock(return_value = (salt, pwd_hash, "jwt", False)), + ) + with patch.dict("sys.modules", {"auth.storage": storage}): + assert colab._colab_credentials_still_valid("unsloth", "right-pass") is True + assert colab._colab_credentials_still_valid("unsloth", "wrong-pass") is False + + +def test_colab_credentials_still_valid_false_when_user_missing(monkeypatch): + storage = SimpleNamespace(get_user_and_secret = MagicMock(return_value = None)) + with patch.dict("sys.modules", {"auth.storage": storage}): + assert colab._colab_credentials_still_valid("unsloth", "any") is False + + +def test_colab_login_html_includes_credentials(): + html = colab._colab_login_html("unsloth", "alpha-beta-gamma-delta") + assert "unsloth" in html + assert "alpha-beta-gamma-delta" in html + + +def test_show_and_embed_renders_cloudflare_before_colab_login(monkeypatch): + displayed: list[str] = [] + ipython_display = SimpleNamespace( + HTML = lambda html: SimpleNamespace(html = html), + display = lambda html: displayed.append(html.html), + ) + + monkeypatch.setattr(colab, "get_colab_url", lambda port: "https://8888-test.prod.colab.dev/") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "show_link", + lambda port, *, _url = None, has_cloudflare_link = False, cloudflare_requested = False: None, + ) + monkeypatch.setattr(colab, "_embed_kernel_port_iframe", lambda port: True) + with patch.dict("sys.modules", {"IPython.display": ipython_display}): + colab._show_and_embed( + 8888, + cloudflare_url = "https://share.trycloudflare.com", + colab_login = ("unsloth", "secret-pass"), + ) + + assert len(displayed) == 2 + assert "share.trycloudflare.com" in displayed[0] + assert "secret-pass" in displayed[1] + + +def test_show_and_embed_skips_iframe_on_colab_when_cloudflare_ready(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(colab, "get_colab_url", lambda port: f"https://{port}-test.prod.colab.dev/") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "show_link", + lambda port, *, _url = None, has_cloudflare_link = False, cloudflare_requested = False: None, + ) + monkeypatch.setattr( + colab, + "_embed_kernel_port_iframe", + lambda port: calls.append("kernel_iframe") or True, + ) + monkeypatch.setattr( + colab, + "_embed_html_iframe", + lambda url, port: calls.append("html_iframe") or True, + ) + + colab._show_and_embed(8888, cloudflare_url = "https://share.trycloudflare.com") + + assert calls == [] + + +def test_show_and_embed_uses_kernel_helper_on_colab_runtime_despite_localhost(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(colab, "get_colab_url", lambda port: f"http://localhost:{port}") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + + monkeypatch.setattr( + colab, + "show_link", + lambda port, + *, + _url = None, + has_cloudflare_link = False, + cloudflare_requested = False: calls.append("show_link"), + ) + monkeypatch.setattr( + colab, + "_embed_kernel_port_iframe", + lambda port: calls.append("kernel_iframe") or True, + ) + monkeypatch.setattr( + colab, + "_embed_html_iframe", + lambda url, port: calls.append("html_iframe") or True, + ) + + colab._show_and_embed(8888) + + assert calls == ["show_link", "kernel_iframe"] + + +def test_show_and_embed_skips_kernel_helper_for_localhost_outside_colab(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(colab, "get_colab_url", lambda port: f"http://localhost:{port}") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: False) + + monkeypatch.setattr( + colab, + "show_link", + lambda port, + *, + _url = None, + has_cloudflare_link = False, + cloudflare_requested = False: calls.append("show_link"), + ) + monkeypatch.setattr( + colab, + "_embed_kernel_port_iframe", + lambda port: calls.append("kernel_iframe") or True, + ) + monkeypatch.setattr( + colab, + "_embed_html_iframe", + lambda url, port: calls.append("html_iframe") or True, + ) + + colab._show_and_embed(8888) + + assert calls == ["show_link", "html_iframe"] + + +def test_show_and_embed_still_embeds_when_show_link_fails(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(colab, "get_colab_url", lambda port: f"https://{port}-test.prod.colab.dev/") + monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True) + monkeypatch.setattr( + colab, + "show_link", + lambda port, *, _url = None: (_ for _ in ()).throw(RuntimeError("no display")), + ) + monkeypatch.setattr( + colab, + "_embed_kernel_port_iframe", + lambda port: calls.append("kernel_iframe") or True, + ) + monkeypatch.setattr( + colab, + "_embed_html_iframe", + lambda url, port: calls.append("html_iframe") or True, + ) + + colab._show_and_embed(8888) + + assert calls == ["kernel_iframe"] diff --git a/studio/backend/tests/test_file_security.py b/studio/backend/tests/test_file_security.py index b4c8f5d242..e02c33a0f1 100644 --- a/studio/backend/tests/test_file_security.py +++ b/studio/backend/tests/test_file_security.py @@ -165,6 +165,23 @@ def test_skips_local_path(): assert "local" in d.reason +def test_scans_inactive_hf_cache_snapshot_path(tmp_path): + # An inactive HF cache loads by snapshot path; the gate must recover the repo id + + # commit from models--org--repo/snapshots/ and scan that exact commit, not exempt + # it and not fall back to the default branch (an older commit may hold a dropped pickle). + snapshot = tmp_path / "models--evil--repo" / "snapshots" / "deadbeef" + snapshot.mkdir(parents = True) + status = { + "scansDone": True, + "filesWithIssues": [{"path": "pytorch_model.bin", "level": "unsafe"}], + } + with _patch_status(status) as model_info: + d = evaluate_file_security(str(snapshot)) + assert d.blocked is True + assert model_info.call_args.args[0] == "evil/repo" + assert model_info.call_args.kwargs["revision"] == "deadbeef" + + def test_remote_gguf_named_repo_is_still_scanned(): # Only LOCAL paths skip the Hub scan, so a remote .gguf repo is still scanned and a # poisoned pickle smuggled into it is blocked. diff --git a/studio/backend/tests/test_gguf_load_cache_reuse.py b/studio/backend/tests/test_gguf_load_cache_reuse.py index 6d1fac980b..dfd6fc6034 100644 --- a/studio/backend/tests/test_gguf_load_cache_reuse.py +++ b/studio/backend/tests/test_gguf_load_cache_reuse.py @@ -789,9 +789,12 @@ class TestLoadHubDownloadExclusion: # The gguf_load_in_flight marker must be entered before the hub-download # guard and the unload so a concurrent load can't race the download - # manager. The llama_extra_args inheritance that used to sit between the - # marker and the guard now runs in _guard_chat_load_against_training, ahead - # of the GGUF branch, so it is no longer a landmark inside this slice. + # manager. The llama_extra_args inheritance moved out of the branch into + # _resolve_inherited_extra_args, which must still run BEFORE it: the + # inherited value (e.g. a carried --no-mmproj) shapes the guard's + # require_mmproj. Anchor on the call form so the assertion pins the + # endpoint's call site, not the function definition. + assert source.index("= _resolve_inherited_extra_args(") < source.index("if config.is_gguf:") assert ( gguf_branch.index("enter_context(gguf_load_in_flight") < gguf_branch.index("_hub_download_blocks_gguf_load") diff --git a/studio/backend/tests/test_llama_cpp_mmproj_fallback.py b/studio/backend/tests/test_llama_cpp_mmproj_fallback.py index 049058e511..4332a440a5 100644 --- a/studio/backend/tests/test_llama_cpp_mmproj_fallback.py +++ b/studio/backend/tests/test_llama_cpp_mmproj_fallback.py @@ -335,3 +335,24 @@ class TestRetryContract: def test_external_kill_skips_flash_attn_retry(self): # SIGKILL (-9, OOM killer) is not a program fault: no FA-off retry. assert _signal_crash(-9) is False + + +class TestMmprojRetryFailureMessage: + """#7302: bare mmproj crashes must not be reported as projector-format.""" + + def test_confirmed_projector_keeps_historical_wording(self): + msg = LlamaCppBackend._mmproj_retry_failure_message( + projector_confirmed = True, + detail = "llama-server failed to start", + ) + assert msg.startswith("Vision projector incompatible with this llama.cpp") + assert "llama-server failed to start" in msg + + def test_bare_crash_does_not_claim_projector_incompatibility(self): + msg = LlamaCppBackend._mmproj_retry_failure_message( + projector_confirmed = False, + detail = "llama-server failed to start. Check that the GGUF file is valid", + ) + assert "Vision projector incompatible" not in msg + assert "crashed with --mmproj" in msg + assert "GGUF file is valid" in msg diff --git a/studio/backend/tests/test_llama_cpp_mtp_detection.py b/studio/backend/tests/test_llama_cpp_mtp_detection.py index 1d15647967..27c1b17a85 100644 --- a/studio/backend/tests/test_llama_cpp_mtp_detection.py +++ b/studio/backend/tests/test_llama_cpp_mtp_detection.py @@ -637,7 +637,9 @@ def test_probe_server_capabilities_uses_binary_library_env(tmp_path, monkeypatch def fake_run(cmd, **kwargs): captured["cmd"] = cmd captured["env"] = kwargs.get("env") - return _types.SimpleNamespace(stdout = "--spec-type none,mtp,ngram-simple\n", stderr = "") + return _types.SimpleNamespace( + stdout = "--spec-type none,mtp,ngram-simple\n", stderr = "", returncode = 0 + ) monkeypatch.setattr("core.inference.llama_cpp.subprocess.run", fake_run) @@ -678,6 +680,95 @@ def test_probe_server_capabilities_reports_outdated_binary(tmp_path): assert caps["found"] is True assert caps["mtp_token"] is None assert caps["supports_mtp"] is False + assert caps["mtp_probe_inconclusive"] is False + + +@_NEEDS_BASH +def test_probe_server_capabilities_reads_mtp_from_multiline_help(tmp_path): + # Enum on the indented line: first-line-only probing falsely reported + # "lacks MTP" (#7302). + fake = _make_fake_llama_server( + tmp_path / "llama-server", + "--spec-type TYPE\n" + " speculative decoding type\n" + " (none,draft-simple,draft-mtp,ngram-mod)\n", + ) + _clear_caps_cache() + caps = LlamaCppBackend.probe_server_capabilities(str(fake)) + assert caps["mtp_token"] == "draft-mtp" + assert caps["supports_mtp"] is True + assert caps["mtp_probe_inconclusive"] is False + + +@_NEEDS_BASH +def test_probe_server_capabilities_empty_help_fails_open(tmp_path): + # --help prints nothing: must not claim the prebuilt lacks MTP (#7302). + fake = tmp_path / "llama-server" + fake.write_text("#!/usr/bin/env bash\nexit 0\n") + fake.chmod(0o755) + _clear_caps_cache() + caps = LlamaCppBackend.probe_server_capabilities(str(fake)) + assert caps["found"] is True + assert caps["mtp_token"] is None + assert caps["supports_mtp"] is False + assert caps["mtp_probe_inconclusive"] is True + + +@_NEEDS_BASH +def test_probe_server_capabilities_no_spec_type_is_definitive(tmp_path): + # Nonempty --help without --spec-type: pre-spec binary, not inconclusive. + fake = _make_fake_llama_server( + tmp_path / "llama-server", + "--gpu-layers N\n GPU layers to offload\n", + ) + _clear_caps_cache() + caps = LlamaCppBackend.probe_server_capabilities(str(fake)) + assert caps["found"] is True + assert caps["mtp_token"] is None + assert caps["supports_mtp"] is False + assert caps["mtp_probe_inconclusive"] is False + + +@_NEEDS_BASH +def test_probe_server_capabilities_failed_help_with_output_is_inconclusive(tmp_path): + fake = tmp_path / "llama-server" + fake.write_text( + "#!/usr/bin/env bash\n" + 'if [ "$1" = "--help" ]; then\n' + " echo 'illegal instruction'\n" + " exit 1\n" + "fi\n" + ) + fake.chmod(0o755) + _clear_caps_cache() + caps = LlamaCppBackend.probe_server_capabilities(str(fake)) + assert caps["found"] is True + assert caps["supports_mtp"] is False + assert caps["mtp_probe_inconclusive"] is True + + +@_NEEDS_BASH +def test_probe_server_capabilities_crash_on_help_fails_open(tmp_path): + fake = tmp_path / "llama-server" + fake.write_text("#!/usr/bin/env bash\nkill -SEGV $$\n") + fake.chmod(0o755) + _clear_caps_cache() + caps = LlamaCppBackend.probe_server_capabilities(str(fake)) + assert caps["found"] is True + assert caps["mtp_token"] is None + assert caps["supports_mtp"] is False + assert caps["mtp_probe_inconclusive"] is True + + +def test_mtp_token_from_spec_help_prefers_draft_mtp(): + assert ( + LlamaCppBackend._mtp_token_from_spec_help("--spec-type none,draft-mtp,mtp,ngram-mod") + == "draft-mtp" + ) + assert LlamaCppBackend._mtp_token_from_spec_help("--spec-type [none|mtp|ngram-cache]") == "mtp" + assert LlamaCppBackend._mtp_token_from_spec_help("--spec-type none,ngram-mod") is None + # No incidental substring matches. + assert LlamaCppBackend._mtp_token_from_spec_help("prompt cache") is None def test_probe_server_capabilities_handles_missing_binary(): @@ -685,6 +776,7 @@ def test_probe_server_capabilities_handles_missing_binary(): caps = LlamaCppBackend.probe_server_capabilities("/no/such/llama-server") assert caps["found"] is False assert caps["supports_mtp"] is False + assert caps["mtp_probe_inconclusive"] is True assert caps["supports_cache_ram"] is False assert caps["supports_ctx_checkpoints"] is False assert caps["supports_no_cache_prompt"] is False @@ -1176,12 +1268,14 @@ def _resolver_backend( *, ngram_supported = True, mtp_token = "draft-mtp", + mtp_probe_inconclusive = False, ): """Backend with a deterministic probe so the resolver is hermetic.""" fake = { "found": True, "mtp_token": mtp_token, "supports_mtp": bool(mtp_token), + "mtp_probe_inconclusive": mtp_probe_inconclusive, "ngram_mod_flavor": "new" if ngram_supported else None, "supports_ngram_mod": bool(ngram_supported), "spec_draft_n_max_flag": "--spec-draft-n-max", @@ -1879,6 +1973,24 @@ def test_spec_fallback_reason_set_when_binary_lacks_mtp(monkeypatch): assert backend.spec_fallback_reason == "binary_no_mtp" +def test_spec_fallback_reason_none_when_mtp_probe_inconclusive(monkeypatch): + backend = _resolver_backend( + monkeypatch, + mtp_token = None, + mtp_probe_inconclusive = True, + ) + backend._build_speculative_flags( + speculative_type = "mtp", + spec_draft_n_max = None, + extra_args = None, + model_identifier = _MTP_MODEL, + model_path = None, + gpus = True, + binary = "/fake/llama-server", + ) + assert backend.spec_fallback_reason is None + + def test_spec_fallback_reason_none_when_mtp_engages(monkeypatch): backend = _resolver_backend(monkeypatch) backend._build_speculative_flags( diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py index e99e227d40..cf9fde7118 100644 --- a/studio/backend/tests/test_llama_cpp_tool_loop.py +++ b/studio/backend/tests/test_llama_cpp_tool_loop.py @@ -37,6 +37,24 @@ def _done() -> str: return "data: [DONE]\n" +def _finish(reason: str) -> str: + return ( + "data: " + + json.dumps( + { + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": reason, + } + ] + } + ) + + "\n" + ) + + def _make_backend(monkeypatch, streams: list[list[str]], payloads: list[dict]): backend = LlamaCppBackend.__new__(LlamaCppBackend) backend._process = object() @@ -299,9 +317,8 @@ def test_reasoning_streams_incrementally_with_tools(monkeypatch): def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch): # A reasoning-only turn (whole answer in reasoning_content, no content, no # tool) with a tool active streams the reasoning live, then resolves to the - # bare reasoning text -- identical to the no-tool generate_chat_completion - # path -- so the non-streaming drain still returns it as `content`, not an - # empty answer. + # same text on the visible channel. The final cumulative snapshot stays + # append-only so route suffix extraction cannot drop that fallback. stream = [ _sse({"reasoning_content": "The capital of France is Paris."}), _done(), @@ -321,8 +338,49 @@ def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch): content_texts = [e["text"] for e in events if e["type"] == "content"] # Reasoning streamed live during BUFFERING (the fix). assert content_texts[0] == "The capital of France is Paris." - # Resolves to bare reasoning, matching the no-tool sibling. - assert content_texts[-1] == "The capital of France is Paris." + assert content_texts[-1] == ( + "The capital of France is Paris.The capital of France is Paris." + ) + + +def _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, with_tools): + stream = [ + _sse({"reasoning_content": "The capital of France is Paris."}), + _done(), + ] + backend = _make_backend(monkeypatch, [stream], []) + + if with_tools: + items = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "capital of France?"}], + tools = [{"type": "function", "function": {"name": "web_search"}}], + max_tool_iterations = 1, + promote_reasoning_only = False, + ) + ) + cumulatives = [item["text"] for item in items if item.get("type") == "content"] + else: + items = list( + backend.generate_chat_completion( + messages = [{"role": "user", "content": "capital of France?"}], + promote_reasoning_only = False, + ) + ) + cumulatives = [item for item in items if isinstance(item, str)] + + assert cumulatives[-1] == "The capital of France is Paris." + assert all( + current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives) + ) + + +def test_reasoning_only_raw_consumer_without_tools_gets_one_balanced_think_block(monkeypatch): + _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, False) + + +def test_reasoning_only_raw_consumer_with_tools_gets_one_balanced_think_block(monkeypatch): + _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, True) def test_reasoning_before_structured_tool_closes_think_block(monkeypatch): @@ -392,8 +450,8 @@ def _replay_route_reasoning_extractor(cumulatives: list[str]) -> tuple[str, str] def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch): # Parity contract: a reasoning-only reply must reach the client identically # whether tools are on or off. Both generators stream live then - # resolve to the bare reasoning text; the route's suffix-diff + extractor - # must therefore produce the same (visible, reasoning) split for both. + # append a balanced close plus visible fallback; the route's suffix-diff + + # extractor must therefore produce the same split for both. stream = [ _sse({"reasoning_content": "The capital"}), _sse({"reasoning_content": " of France is Paris."}), @@ -430,10 +488,37 @@ def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch): no_tool_out = _replay_route_reasoning_extractor(no_tool_cumulatives) assert tool_out == no_tool_out # Pin the shared contract so a change to either path shows up here. - _visible, reasoning = tool_out + visible, reasoning = tool_out + assert visible == "The capital of France is Paris." assert reasoning == "The capital of France is Paris." +def test_length_truncated_reasoning_stays_append_only_without_visible_promotion(monkeypatch): + stream = [ + _sse({"reasoning_content": "The proof begins by assuming finitely many primes."}), + _finish("length"), + _done(), + ] + backend = _make_backend(monkeypatch, [stream], []) + + items = list( + backend.generate_chat_completion( + messages = [{"role": "user", "content": "Prove infinitely many primes"}], + max_tokens = 16, + ) + ) + cumulatives = [item for item in items if isinstance(item, str)] + + assert all( + current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives) + ) + assert cumulatives[-1] == ("The proof begins by assuming finitely many primes.") + visible, reasoning = _replay_route_reasoning_extractor(cumulatives) + assert visible == "" + assert reasoning == "The proof begins by assuming finitely many primes." + assert items[-1]["finish_reason"] == "length" + + def test_reasoning_before_bare_json_tool_closes_think_block(monkeypatch): # _drain_silently sibling of the structured-tool close: a bare-JSON tool call # with a live reasoning prefix must also close before draining, and diff --git a/studio/backend/tests/test_training_config_popover_source.py b/studio/backend/tests/test_training_config_popover_source.py index 4263b012eb..452a3a1ea8 100644 --- a/studio/backend/tests/test_training_config_popover_source.py +++ b/studio/backend/tests/test_training_config_popover_source.py @@ -105,5 +105,6 @@ def test_shared_mapper_matches_backend_config_keys(): "lora_dropout", "use_rslora", "use_loftq", + "use_dora", ): assert key in src, f"run-config mapper lost backend key {key}" diff --git a/studio/backend/utils/security/file_security.py b/studio/backend/utils/security/file_security.py index 892f7862a9..3e12c15096 100644 --- a/studio/backend/utils/security/file_security.py +++ b/studio/backend/utils/security/file_security.py @@ -114,6 +114,28 @@ def _file_suffix(path: str) -> str: return "." + base.rsplit(".", 1)[1].lower() if "." in base else "" +def _hf_cache_snapshot_ref(local_path: str) -> Optional[tuple]: + """``(repo_id, revision)`` for an HF-cache snapshot path, else None. An inactive Studio + cache loads by its snapshot path but keeps the ``models--org--repo/snapshots/`` + layout, so the gate recovers its provenance and scans that exact commit instead of + exempting it (an older cached commit can hold a pickle since dropped from the branch).""" + try: + path = Path(local_path).resolve(strict = False) + except (OSError, ValueError): + return None + for parent in path.parents: + if parent.name != "snapshots": + continue + encoded = parent.parent.name + if not encoded.startswith("models--"): + return None + repo_id = encoded.removeprefix("models--").replace("--", "/") + if not repo_id: + return None + return repo_id, path.relative_to(parent).parts[0] # dir under snapshots/ + return None + + def _load_relative_path(norm: str, load_subdirs) -> str: """``norm`` relative to a ``from_pretrained`` load root. Some loads read from a snapshot SUBDIRECTORY (Spark-TTS / BiCodec load ``/LLM``), where a file @@ -141,13 +163,14 @@ def _indexed_shard_paths( model_name: str, hf_token: Optional[str], load_subdirs = (), + revision: Optional[str] = None, ): """Repo-relative weight paths a load could fetch via weight-index files. Returns a set (empty when the repo ships no index files -- a definitive "nothing sharded"), or None when the lookup was inconclusive (transient error) so the caller treats a flagged subdir pickle conservatively. Reads only small JSON indexes, never weights. Indexes are looked up at the root and each ``load_subdirs`` root, with ``weight_map`` - entries re-prefixed to repo-relative paths. + entries re-prefixed to repo-relative paths. ``revision`` scopes to a cached commit. """ import json @@ -166,6 +189,7 @@ def _indexed_shard_paths( index_path = hf_hub_download( model_name, prefix + filename, + revision = revision, token = hf_token or None, cache_dir = active_hf_hub_cache(), ) @@ -260,9 +284,14 @@ def _load_scan_target(model_name: str, load_subdirs: tuple) -> tuple: return model_name, load_subdirs -def _fetch_security_status(model_name: str, hf_token: Optional[str]): +def _fetch_security_status( + model_name: str, + hf_token: Optional[str], + revision: Optional[str] = None, +): """``security_repo_status`` (a dict) or None if unavailable. Hub metadata only; retries once on a transient error, then returns None so the caller fails open. + ``revision`` scopes the scan to a specific cached commit (else the default branch). """ from huggingface_hub import model_info as hf_model_info @@ -272,6 +301,7 @@ def _fetch_security_status(model_name: str, hf_token: Optional[str]): try: info = hf_model_info( model_name, + revision = revision, token = token_arg, securityStatus = True, timeout = timeout, @@ -485,12 +515,17 @@ def evaluate_file_security( # fails open): the Spark-TTS "/LLM" alias is really unsloth/ from LLM/. model_name, load_subdirs = _load_scan_target(model_name, tuple(load_subdirs)) - # Local paths (including a local .gguf) have no Hub scan. A remote ref is scanned - # even if named "*.gguf", so a repo cannot dodge the scan via its name. + # Local paths have no Hub scan, EXCEPT an HF-cache snapshot whose canonical path + # encodes a repo id + commit: scan that exact commit so an inactive-cache load can't + # dodge the gate. A remote ref is scanned even if named "*.gguf" (name can't dodge it). + snapshot_revision = None try: from utils.paths import is_local_path if is_local_path(model_name): - return FileSecurityDecision(model_name, False, reason = "local path; no Hub scan") + cache_ref = _hf_cache_snapshot_ref(model_name) + if cache_ref is None: + return FileSecurityDecision(model_name, False, reason = "local path; no Hub scan") + model_name, snapshot_revision = cache_ref except Exception: # Cannot classify the path -> do not block on that account. return FileSecurityDecision(model_name, False, reason = "path check failed; not blocked") @@ -499,7 +534,7 @@ def evaluate_file_security( if local_only_load: return _evaluate_local_only(model_name) - status = _fetch_security_status(model_name, hf_token) + status = _fetch_security_status(model_name, hf_token, revision = snapshot_revision) if not isinstance(status, dict): return FileSecurityDecision( model_name, False, reason = "scan unavailable; allowed (fail-open)" @@ -536,7 +571,9 @@ def evaluate_file_security( maybe_shard.append({"path": path, "level": level, "norm": norm}) if maybe_shard: - indexed = _indexed_shard_paths(model_name, hf_token, load_subdirs) + indexed = _indexed_shard_paths( + model_name, hf_token, load_subdirs, revision = snapshot_revision + ) for m in maybe_shard: # Block if a root index lists this shard, or if the lookup was inconclusive # (transient error -> stay conservative). A definitive "no index / not listed" diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index e6c89b9cd7..9232defd70 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -524,8 +524,10 @@ export function AppProvider({ children }: AppProviderProps) { visibleToasts={2} expand={true} closeButton={true} - // Clear the chat header buttons on the right. - offset={{ top: 12, right: 64 }} + // Clear the chat header buttons on the right. On desktop, also drop + // below the ~34px custom window titlebar so toasts don't cover the + // minimize / maximize / close controls. + offset={{ top: isTauri ? 46 : 12, right: 64 }} /> diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 2054384e5a..5976b0af96 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -89,6 +89,7 @@ import { import { resolveLoadMaxSeqLength } from "../presets/preset-policy"; import { generateAudio, + GenerationLengthError, listCachedGguf, listCachedModels, listGgufVariants, @@ -4100,7 +4101,15 @@ export function createOpenAIStreamAdapter( ); if (!abortSignal.aborted) { const msg = err instanceof Error ? err.message : String(err); - if (err instanceof StreamInterruptedError) { + if (err instanceof GenerationLengthError) { + toast.error("Response ran out of tokens", { + description: + "The model used the full Max Tokens budget while thinking " + + "and did not produce a final answer. Increase Max Tokens in " + + "chat Settings or turn off thinking, then retry.", + duration: 8000, + }); + } else if (err instanceof StreamInterruptedError) { // Connection dropped mid-turn: surface it explicitly (the rethrow // below also marks the message with an inline error + Retry). toast.error("Response interrupted", { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ffaf099f29..4d123e98ab 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -50,6 +50,21 @@ export class StreamInterruptedError extends Error { } } +/** + * Thrown when a reasoning model consumes its output budget before emitting any + * standard content. Keeping this distinct from a dropped connection lets the + * chat UI explain why a completed stream contains only a thinking panel. + */ +export class GenerationLengthError extends Error { + constructor() { + super( + "The model reached the Max Tokens limit before producing a final answer. " + + "Increase Max Tokens or disable thinking, then retry.", + ); + this.name = "GenerationLengthError"; + } +} + export function notifyChatHistoryUpdated(): void { if (typeof window !== "undefined") { window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT)); @@ -982,6 +997,61 @@ function parseSseEvent(rawEvent: string): string[] { return dataLines; } +function hasNonWhitespaceText(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.some((item) => hasNonWhitespaceText(item)); + } + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ["thinking", "text", "content", "reasoning", "summary"].some( + (key) => key in record && hasNonWhitespaceText(record[key]), + ); +} + +function classifyStructuredDeltaContent(content: unknown): { + hasAssistantContent: boolean; + hasReasoningContent: boolean; +} { + if (typeof content === "string") { + return { + hasAssistantContent: hasNonWhitespaceText(content), + hasReasoningContent: false, + }; + } + if (!Array.isArray(content)) { + return { + hasAssistantContent: false, + hasReasoningContent: false, + }; + } + + let hasAssistantContent = false; + let hasReasoningContent = false; + for (const part of content) { + if (typeof part === "string") { + hasAssistantContent ||= hasNonWhitespaceText(part); + continue; + } + if (!part || typeof part !== "object") { + continue; + } + const record = part as Record; + if (record.type === "thinking" || record.type === "reasoning") { + hasReasoningContent ||= hasNonWhitespaceText(record); + } else if (record.type === "text" || record.type === "output_text") { + const text = + typeof record.text === "string" ? record.text : record.content; + hasAssistantContent ||= hasNonWhitespaceText(text); + } + } + return { hasAssistantContent, hasReasoningContent }; +} + export async function* streamChatCompletions( payload: OpenAIChatCompletionsRequest, signal: AbortSignal, @@ -1009,6 +1079,19 @@ export async function* streamChatCompletions( // EOF without `[DONE]` or a finish_reason chunk means the stream was cut // mid-generation: surface as interrupted, not silent success. let sawTerminalSignal = false; + let terminalFinishReason: string | null = null; + let sawAssistantContent = false; + let sawReasoningContent = false; + + const throwIfReasoningOnlyLength = () => { + if ( + terminalFinishReason === "length" && + sawReasoningContent && + !sawAssistantContent + ) { + throw new GenerationLengthError(); + } + }; try { while (true) { @@ -1018,6 +1101,7 @@ export async function* streamChatCompletions( if (!sawTerminalSignal) { throw new StreamInterruptedError(); } + throwIfReasoningOnlyLength(); break; } @@ -1039,6 +1123,7 @@ export async function* streamChatCompletions( if (dataText === "[DONE]") { completed = true; sawTerminalSignal = true; + throwIfReasoningOnlyLength(); return; } @@ -1094,11 +1179,31 @@ export async function* streamChatCompletions( } // finish_reason is a valid terminal signal for providers that close // the stream without an explicit [DONE] sentinel. - const finishReason = ( + const parsedChoices = ( parsed as { - choices?: Array<{ finish_reason?: string | null }>; + choices?: Array<{ + delta?: Record; + finish_reason?: string | null; + }>; } - ).choices?.[0]?.finish_reason; + ).choices; + for (const choice of parsedChoices ?? []) { + const delta = choice.delta; + if (delta) { + const contentState = classifyStructuredDeltaContent(delta.content); + sawAssistantContent ||= contentState.hasAssistantContent; + sawReasoningContent ||= contentState.hasReasoningContent; + const reasoning = + delta.reasoning_content ?? + delta.reasoning ?? + delta.reasoning_details; + sawReasoningContent ||= hasNonWhitespaceText(reasoning); + } + if (choice.finish_reason) { + terminalFinishReason = choice.finish_reason; + } + } + const finishReason = parsedChoices?.[0]?.finish_reason; if (finishReason) { sawTerminalSignal = true; } diff --git a/studio/frontend/src/features/chat/api/chat-settings-api.ts b/studio/frontend/src/features/chat/api/chat-settings-api.ts index 1e00357ea4..ab13130525 100644 --- a/studio/frontend/src/features/chat/api/chat-settings-api.ts +++ b/studio/frontend/src/features/chat/api/chat-settings-api.ts @@ -13,6 +13,7 @@ export type PersistedInferenceParams = Partial< export interface PersistedChatPreset { name: string; params: PersistedInferenceParams; + loadConfig?: Record; } export interface PersistedChatSettings { diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index 0f4c83e0db..1955c3aca1 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -12,6 +12,8 @@ import { import { MascotImg } from "@/components/mascot-img"; import { Button } from "@/components/ui/button"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { downloadFile, isDownloadCancelled } from "@/lib/native-files"; +import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; import { CopyIcon, EyeIcon, Maximize2Icon, XIcon } from "lucide-react"; import { Download01Icon } from "@hugeicons/core-free-icons"; @@ -91,18 +93,6 @@ function ArtifactGeneratingPanel() { ); } -function downloadTextFile(filename: string, text: string): void { - const blob = new Blob([text], { type: "text/html;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - window.setTimeout(() => URL.revokeObjectURL(url), 0); -} - export function ArtifactSurface({ artifact, variant, @@ -205,7 +195,7 @@ export function ArtifactSurface({ className={cn( "relative flex min-h-0 flex-col bg-background", variant === "panel" - ? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95" + ? "artifact-panel-shell mx-2 mt-[90px] mb-8 h-[calc(100%_-_122px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95" : "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl border border-border shadow-xl", )} aria-label={`${artifact.title} canvas`} @@ -265,7 +255,19 @@ export function ArtifactSurface({ size="icon" className="size-8" disabled={isLoadingArtifact || !hasArtifactCode} - onClick={() => downloadTextFile(filename, artifact.code)} + onClick={() => { + // Route through the native save dialog on desktop; the plain + // blob-anchor download is silently dropped by the Tauri WebView2. + void downloadFile( + artifact.code, + filename, + "text/html;charset=utf-8", + ).catch((err) => { + if (!isDownloadCancelled(err)) { + toast.error("Failed to save canvas HTML"); + } + }); + }} aria-label="Download canvas HTML" > diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index c241607e28..7452cf3447 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -2289,7 +2289,7 @@ export function ChatPage({ } else if (outcome === "conflict") { toast.info("Resume this download from Models", { description: - "An earlier partial download used a different transport. Open the Models tab to resume or restart it.", + "An earlier partial download used a different transport. Open the Model hub tab to resume or restart it.", }); } else if (outcome === "busy") { toast.info("Download already in progress", { @@ -2410,7 +2410,7 @@ export function ChatPage({ // surface's onComplete auto-loads, mirroring the "started" branch. toast.info("Resume this download from Models", { description: - "An earlier partial download used a different transport. Open the Models tab to resume or restart it.", + "An earlier partial download used a different transport. Open the Model hub tab to resume or restart it.", }); return; } diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 672ab2b2d3..7b310c50d4 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -75,6 +75,12 @@ import { isSamePresetConfig, toPresetParams, } from "./presets/preset-policy"; +import { + applyPresetLoadConfig, + capturePresetLoadConfig, + formatPresetLoadConfigSummary, + isSamePresetLoadConfig, +} from "./presets/preset-load-config"; import { type ProviderCapabilities, getExternalMaxOutputTokens, @@ -385,6 +391,12 @@ export function ChatSettingsPanel({ (s) => s.ggufMaxContextLength, ); const customContextLength = useChatRuntimeStore((s) => s.customContextLength); + const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype); + const gpuMemoryMode = useChatRuntimeStore((s) => s.gpuMemoryMode); + const gpuLayers = useChatRuntimeStore((s) => s.gpuLayers); + const nCpuMoe = useChatRuntimeStore((s) => s.nCpuMoe); + const tensorParallel = useChatRuntimeStore((s) => s.tensorParallel); + const specDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax); const speculativeType = useChatRuntimeStore((s) => s.speculativeType); const specFallbackReason = useChatRuntimeStore((s) => s.specFallbackReason); const mtpUpdatable = @@ -469,11 +481,50 @@ export function ChatSettingsPanel({ if (activePresetDefinition == null) { return false; } - if (activePresetDefinition.name === "Default") { - return activePresetSource === "modified"; - } - return !isSamePresetConfig(activePresetDefinition.params, params); - }, [activePresetDefinition, activePresetSource, params]); + const samplingChanged = + activePresetDefinition.name === "Default" + ? activePresetSource === "modified" + : !isSamePresetConfig(activePresetDefinition.params, params); + const currentLoadConfig = capturePresetLoadConfig(); + const loadChanged = !isSamePresetLoadConfig( + activePresetDefinition.loadConfig, + currentLoadConfig, + ); + return samplingChanged || loadChanged; + }, [ + activePresetDefinition, + activePresetSource, + params, + customContextLength, + ggufContextLength, + kvCacheDtype, + gpuMemoryMode, + gpuLayers, + nCpuMoe, + tensorParallel, + speculativeType, + specDraftNMax, + params.maxSeqLength, + ]); + const activePresetLoadSummary = useMemo( + () => formatPresetLoadConfigSummary(activePresetDefinition?.loadConfig), + [activePresetDefinition], + ); + const currentLoadSummary = useMemo( + () => formatPresetLoadConfigSummary(capturePresetLoadConfig()), + [ + customContextLength, + ggufContextLength, + kvCacheDtype, + gpuMemoryMode, + gpuLayers, + nCpuMoe, + tensorParallel, + speculativeType, + specDraftNMax, + params.maxSeqLength, + ], + ); const presetSaveState = useMemo( () => getPresetSaveState({ @@ -549,8 +600,14 @@ export function ChatSettingsPanel({ onParamsChange({ ...applyPresetParams(params, p.params), }); + if (p.loadConfig) { + applyPresetLoadConfig(p.loadConfig); + } setActivePreset(name); setActivePresetSource(getPresetSource(name)); + if (p.loadConfig && params.checkpoint) { + toast.info("Reload the model to apply load settings from this preset."); + } } } @@ -571,9 +628,14 @@ export function ChatSettingsPanel({ ? getBuiltinVariantName(trimmed, usedNames) : trimmed; const next = customPresets.filter((p) => p.name !== saveName); + const loadConfig = capturePresetLoadConfig(); const merged = [ ...next, - { name: saveName, params: toPresetParams(params) }, + { + name: saveName, + params: toPresetParams(params), + ...(loadConfig ? { loadConfig } : {}), + }, ]; setCustomPresets(merged); setActivePreset(saveName); @@ -598,8 +660,11 @@ export function ChatSettingsPanel({ if (activePreset === name) { if (fallbackPreset) { onParamsChange({ - ...applyPresetParams(params, fallbackPreset.params), + ... applyPresetParams(params, fallbackPreset.params), }); + if (fallbackPreset.loadConfig) { + applyPresetLoadConfig(fallbackPreset.loadConfig); + } setActivePreset(fallbackPreset.name); setActivePresetSource("builtin-default"); } @@ -901,6 +966,23 @@ export function ChatSettingsPanel({ Delete
+

+ Saving a preset also stores current load settings (context length, + KV cache dtype, speculative decoding, GPU layers). + {currentLoadSummary ? ( + <> + {" "} + Active now: {currentLoadSummary}. + + ) : null} + {activePresetLoadSummary && + activePresetLoadSummary !== currentLoadSummary ? ( + <> + {" "} + Saved in preset: {activePresetLoadSummary}. + + ) : null} +

diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 082f6d6ede..36eff27334 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -1274,12 +1274,19 @@ export function useChatModelRuntime() { prog.expected_bytes, dlSamples, ); - setLoadProgress({ - percent: pct, - label: progressLabel, - phase: "downloading", - }); - if (loadToastDismissedRef.current) return; + // loadProgress state is only read by the dismissed-toast inline + // status. Writing it while the toast is visible re-renders the + // whole chat page every poll — cheap in Chrome, janky in the + // desktop WebView2 (laggy typing). Feed the toast directly and + // only touch state when the inline view is actually live. + if (loadToastDismissedRef.current) { + setLoadProgress({ + percent: pct, + label: progressLabel, + phase: "downloading", + }); + return; + } toast(null, { id: toastId, ...modelLoadToastOptions( @@ -1301,19 +1308,23 @@ export function useChatModelRuntime() { const est = estimate(dlSamples, prog.downloaded_bytes, 0); const rateSuffix = est.stable ? ` • ${formatRate(est.rate)}` : ""; - setLoadProgress({ - percent: null, - label: `${dlGb.toFixed(1)} GB downloaded${rateSuffix}`, - phase: "downloading", - }); + // Inline-status-only state; skip the chat-page re-render unless it's shown. + if (loadToastDismissedRef.current) { + setLoadProgress({ + percent: null, + label: `${dlGb.toFixed(1)} GB downloaded${rateSuffix}`, + phase: "downloading", + }); + } } else if (prog.progress >= 1 && hasShownProgress) { downloadComplete = true; - setLoadProgress({ - percent: 100, - label: "Download complete", - phase: "starting", - }); - if (!loadToastDismissedRef.current) { + if (loadToastDismissedRef.current) { + setLoadProgress({ + percent: 100, + label: "Download complete", + phase: "starting", + }); + } else { toast(null, { id: toastId, ...modelLoadToastOptions( @@ -1367,12 +1378,17 @@ export function useChatModelRuntime() { formatEta(est.eta) !== "--" ? ` • ${formatEta(est.eta)} left` : "" }` : base; - setLoadProgress({ - percent: pct, - label, - phase: "starting", - }); - if (loadToastDismissedRef.current) return; + // Inline-status-only state (see pollDownload): while the toast is + // up, skip the state write so the chat page doesn't re-render every + // poll during "Starting model" — the desktop WebView2 typing-lag fix. + if (loadToastDismissedRef.current) { + setLoadProgress({ + percent: pct, + label, + phase: "starting", + }); + return; + } toast(null, { id: toastId, ...modelLoadToastOptions( diff --git a/studio/frontend/src/features/chat/permission-mode-select.tsx b/studio/frontend/src/features/chat/permission-mode-select.tsx index f3e1594795..2fafeab7d6 100644 --- a/studio/frontend/src/features/chat/permission-mode-select.tsx +++ b/studio/frontend/src/features/chat/permission-mode-select.tsx @@ -1,13 +1,8 @@ // 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 { - ChevronDown, - CircleAlert, - CircleOff, - Hand, - ShieldCheck, -} from "lucide-react"; +import { ChevronDown, CircleAlert, Hand, ShieldCheck } from "lucide-react"; +import type { ComponentType } from "react"; import { useState } from "react"; import { @@ -29,6 +24,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; +import { SparklesGlyph } from "@/lib/sparkles-icon"; import { Tick02Icon } from "@/lib/tick-icon"; import { cn } from "@/lib/utils"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -45,7 +41,7 @@ export const PERMISSION_MODE_OPTIONS: readonly { value: PermissionMode; label: string; description: string; - icon: typeof Hand; + icon: ComponentType<{ className?: string; strokeWidth?: number }>; }[] = [ { value: "ask", @@ -63,7 +59,7 @@ export const PERMISSION_MODE_OPTIONS: readonly { value: "off", label: "Run automatically", description: "Run tool calls without approval prompts inside the sandbox", - icon: CircleOff, + icon: SparklesGlyph, }, { value: "full", diff --git a/studio/frontend/src/features/chat/presets/preset-load-config.ts b/studio/frontend/src/features/chat/presets/preset-load-config.ts new file mode 100644 index 0000000000..1083655cf2 --- /dev/null +++ b/studio/frontend/src/features/chat/presets/preset-load-config.ts @@ -0,0 +1,244 @@ +// 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 { + applyPerModelConfigToRuntime, + currentRuntimePerModelConfig, + perModelConfigsEqual, +} from "@/features/model-picker"; +import { + CONTEXT_LENGTH_MIN, + DEFAULT_PER_MODEL_CONFIG, + DEFAULT_MAX_SEQ_LENGTH, + KV_CACHE_DTYPES, + MTP_SPECULATIVE_TYPES, + SPECULATIVE_TYPES, + normalizeMaxSeqLength, + type PerModelConfig, +} from "@/features/model-picker/model-config/per-model-config"; +import { + GPU_LAYERS_AUTO, + useChatRuntimeStore, + normalizeSpeculativeType, +} from "../stores/chat-runtime-store"; + +/** Load/runtime knobs saved in a chat preset (excludes per-model-only blobs). */ +export type PresetLoadConfig = Pick< + PerModelConfig, + | "customContextLength" + | "maxSeqLength" + | "kvCacheDtype" + | "speculativeType" + | "specDraftNMax" + | "tensorParallel" + | "gpuMemoryMode" + | "gpuLayers" + | "nCpuMoe" +>; + +const VALID_KV_CACHE_DTYPES = new Set(KV_CACHE_DTYPES); +const VALID_SPECULATIVE_TYPES = new Set(SPECULATIVE_TYPES); + +export const EMPTY_PRESET_LOAD_CONFIG: PresetLoadConfig = { + customContextLength: null, + maxSeqLength: null, + kvCacheDtype: null, + speculativeType: null, + specDraftNMax: null, + tensorParallel: false, +}; + +function toComparablePerModelConfig( + config: PresetLoadConfig, +): PerModelConfig { + return { + ...DEFAULT_PER_MODEL_CONFIG, + ...config, + chatTemplateOverride: null, + selectedGpuIds: null, + }; +} + +export function normalizePresetLoadConfig( + raw: unknown, +): PresetLoadConfig | undefined { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const partial = raw as Record; + const rawSpecType = + typeof partial.speculativeType === "string" + ? normalizeSpeculativeType(partial.speculativeType) + : null; + const speculativeType = rawSpecType ?? null; + const specDraftNMax = + speculativeType != null && + MTP_SPECULATIVE_TYPES.has(speculativeType) && + typeof partial.specDraftNMax === "number" && + Number.isFinite(partial.specDraftNMax) + ? Math.max(1, Math.min(16, Math.round(partial.specDraftNMax))) + : null; + const gpuMemoryMode = + partial.gpuMemoryMode === "manual" ? ("manual" as const) : undefined; + let gpuLayers: number | undefined; + if (typeof partial.gpuLayers === "number" && Number.isFinite(partial.gpuLayers)) { + gpuLayers = partial.gpuLayers < 0 ? GPU_LAYERS_AUTO : Math.floor(partial.gpuLayers); + } + let nCpuMoe: number | undefined; + if (typeof partial.nCpuMoe === "number" && Number.isFinite(partial.nCpuMoe)) { + nCpuMoe = Math.max(0, Math.floor(partial.nCpuMoe)); + } + + const normalized: PresetLoadConfig = { + customContextLength: + typeof partial.customContextLength === "number" && + Number.isFinite(partial.customContextLength) && + partial.customContextLength > 0 + ? Math.max(CONTEXT_LENGTH_MIN, Math.floor(partial.customContextLength)) + : null, + maxSeqLength: normalizeMaxSeqLength(partial.maxSeqLength as number | null), + kvCacheDtype: + typeof partial.kvCacheDtype === "string" && + VALID_KV_CACHE_DTYPES.has(partial.kvCacheDtype) + ? partial.kvCacheDtype + : null, + speculativeType: + speculativeType && VALID_SPECULATIVE_TYPES.has(speculativeType) + ? speculativeType + : null, + specDraftNMax, + tensorParallel: + typeof partial.tensorParallel === "boolean" + ? partial.tensorParallel + : false, + ...(gpuMemoryMode ? { gpuMemoryMode } : {}), + ...(gpuLayers !== undefined ? { gpuLayers } : {}), + ...(nCpuMoe !== undefined ? { nCpuMoe } : {}), + }; + + return hasPresetLoadConfig(normalized) ? normalized : undefined; +} + +export function hasPresetLoadConfig( + config?: PresetLoadConfig | null, +): boolean { + return !isSamePresetLoadConfig(config, EMPTY_PRESET_LOAD_CONFIG); +} + +export function isSamePresetLoadConfig( + a?: PresetLoadConfig | null, + b?: PresetLoadConfig | null, +): boolean { + return perModelConfigsEqual( + toComparablePerModelConfig({ ...EMPTY_PRESET_LOAD_CONFIG, ...a }), + toComparablePerModelConfig({ ...EMPTY_PRESET_LOAD_CONFIG, ...b }), + ); +} + +export function capturePresetLoadConfig(): PresetLoadConfig | undefined { + const snapshot = currentRuntimePerModelConfig({ includeMaxSeqLength: true }); + const store = useChatRuntimeStore.getState(); + const isGguf = + store.activeGgufVariant != null || + store.ggufContextLength != null || + (store.params.checkpoint?.toLowerCase().endsWith(".gguf") ?? false); + const effectiveContextLength = + snapshot.customContextLength ?? + (isGguf ? store.ggufContextLength : null); + const captured: PresetLoadConfig = { + customContextLength: effectiveContextLength ?? null, + maxSeqLength: normalizeMaxSeqLength(snapshot.maxSeqLength), + kvCacheDtype: snapshot.kvCacheDtype ?? null, + speculativeType: normalizeSpeculativeType(snapshot.speculativeType), + specDraftNMax: snapshot.specDraftNMax ?? null, + tensorParallel: snapshot.tensorParallel ?? false, + ...(snapshot.gpuMemoryMode === "manual" + ? { gpuMemoryMode: "manual" as const } + : {}), + ...(snapshot.gpuLayers != null && snapshot.gpuLayers >= 0 + ? { gpuLayers: snapshot.gpuLayers } + : snapshot.gpuMemoryMode === "manual" + ? { gpuLayers: GPU_LAYERS_AUTO } + : {}), + ...(snapshot.nCpuMoe != null && snapshot.nCpuMoe > 0 + ? { nCpuMoe: snapshot.nCpuMoe } + : {}), + }; + return hasPresetLoadConfig(coalesceDefaultLoadKnobs(captured)) + ? coalesceDefaultLoadKnobs(captured) + : undefined; +} + +function coalesceDefaultLoadKnobs( + captured: PresetLoadConfig, +): PresetLoadConfig { + const result: PresetLoadConfig = { ...captured }; + if (normalizeMaxSeqLength(result.maxSeqLength) === DEFAULT_MAX_SEQ_LENGTH) { + result.maxSeqLength = null; + } + const speculativeType = normalizeSpeculativeType(result.speculativeType); + if (speculativeType == null || speculativeType === "auto") { + result.speculativeType = null; + } + if ( + (result.gpuLayers == null || result.gpuLayers < 0) && + result.gpuMemoryMode !== "manual" + ) { + delete result.gpuLayers; + } + if ((result.nCpuMoe ?? 0) === 0) { + delete result.nCpuMoe; + } + return result; +} + +export function applyPresetLoadConfig( + config?: PresetLoadConfig | null, +): void { + if (config == null) { + return; + } + const store = useChatRuntimeStore.getState(); + applyPerModelConfigToRuntime({ + ...DEFAULT_PER_MODEL_CONFIG, + maxSeqLength: normalizeMaxSeqLength(config.maxSeqLength) ?? DEFAULT_MAX_SEQ_LENGTH, + customContextLength: config.customContextLength ?? null, + kvCacheDtype: config.kvCacheDtype ?? null, + speculativeType: config.speculativeType ?? null, + specDraftNMax: config.specDraftNMax ?? null, + tensorParallel: config.tensorParallel ?? false, + chatTemplateOverride: null, + gpuMemoryMode: config.gpuMemoryMode, + gpuLayers: config.gpuLayers, + nCpuMoe: config.nCpuMoe, + selectedGpuIds: store.selectedGpuIds, + }); +} + +export function formatPresetLoadConfigSummary( + config?: PresetLoadConfig | null, +): string | null { + if (!config || !hasPresetLoadConfig(config)) { + return null; + } + const parts: string[] = []; + if (config.customContextLength != null) { + parts.push(`Ctx ${config.customContextLength.toLocaleString()}`); + } + if (config.kvCacheDtype) { + parts.push(`KV ${config.kvCacheDtype}`); + } + if (config.speculativeType && config.speculativeType !== "auto") { + parts.push(`Spec ${config.speculativeType}`); + } + if (config.gpuMemoryMode === "manual") { + parts.push("GPU manual"); + } + if (config.gpuLayers != null && config.gpuLayers >= 0) { + parts.push(`${config.gpuLayers} layers`); + } + if (config.tensorParallel) { + parts.push("TP"); + } + return parts.length > 0 ? parts.join(" · ") : null; +} diff --git a/studio/frontend/src/features/chat/presets/preset-policy.ts b/studio/frontend/src/features/chat/presets/preset-policy.ts index 23d79a35e1..61c94707b4 100644 --- a/studio/frontend/src/features/chat/presets/preset-policy.ts +++ b/studio/frontend/src/features/chat/presets/preset-policy.ts @@ -5,12 +5,15 @@ import { DEFAULT_INFERENCE_PARAMS, type InferenceParams, } from "../types/runtime"; +import type { PresetLoadConfig } from "./preset-load-config"; export const defaultInferenceParams = DEFAULT_INFERENCE_PARAMS; export interface Preset { name: string; params: InferenceParams; + /** Optional GGUF/load knobs captured with the preset. */ + loadConfig?: PresetLoadConfig; } export type PresetOwnedParams = Pick< @@ -85,6 +88,7 @@ export function normalizeCustomPresets(presets: Preset[]): Preset[] { return { name, params: preset.params, + ...(preset.loadConfig ? { loadConfig: preset.loadConfig } : {}), }; }) .filter((preset): preset is Preset => preset !== null); diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 2d116c6d41..0d041cc25b 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -15,6 +15,7 @@ import { type Preset, getPresetSource, } from "../presets/preset-policy"; +import { normalizePresetLoadConfig } from "../presets/preset-load-config"; import { getExternalMaxOutputTokens } from "../provider-capabilities"; import { type ChatLoraSummary, @@ -1184,13 +1185,17 @@ function getHydratedCustomPresets( state: ChatRuntimeStore, ): Preset[] { return ( - settings.customPresets?.map((preset) => ({ - name: preset.name, - params: { - ...DEFAULT_INFERENCE_PARAMS, - ...preset.params, - }, - })) ?? state.customPresets + settings.customPresets?.map((preset) => { + const loadConfig = normalizePresetLoadConfig(preset.loadConfig); + return { + name: preset.name, + params: { + ...DEFAULT_INFERENCE_PARAMS, + ...preset.params, + }, + ...(loadConfig ? { loadConfig } : {}), + }; + }) ?? state.customPresets ); } diff --git a/studio/frontend/src/features/chat/utils/chat-settings-storage.ts b/studio/frontend/src/features/chat/utils/chat-settings-storage.ts index 4899cb9c83..48acfdee40 100644 --- a/studio/frontend/src/features/chat/utils/chat-settings-storage.ts +++ b/studio/frontend/src/features/chat/utils/chat-settings-storage.ts @@ -8,6 +8,7 @@ import { type PersistedChatSettings, type PersistedInferenceParams, } from "../api/chat-settings-api"; +import { normalizePresetLoadConfig } from "../presets/preset-load-config"; import { BUILTIN_PRESETS, defaultInferenceParams, @@ -152,6 +153,7 @@ function sanitizeInferenceParams( } function toFullPreset(preset: PersistedChatPreset): Preset { + const loadConfig = normalizePresetLoadConfig(preset.loadConfig); return { name: preset.name, params: { @@ -159,6 +161,7 @@ function toFullPreset(preset: PersistedChatPreset): Preset { ...preset.params, checkpoint: defaultInferenceParams.checkpoint, }, + ...(loadConfig ? { loadConfig } : {}), }; } @@ -174,7 +177,12 @@ function sanitizeCustomPresets( const name = item.name.trim(); if (!name) return null; const params = sanitizeInferenceParams(item.params); - return { name, params: params ?? {} }; + const loadConfig = normalizePresetLoadConfig(item.loadConfig); + return { + name, + params: params ?? {}, + ...(loadConfig ? { loadConfig } : {}), + }; }) .filter((preset): preset is PersistedChatPreset => preset !== null); @@ -183,6 +191,7 @@ function sanitizeCustomPresets( (preset, index) => ({ name: preset.name, params: presets[index]?.params ?? {}, + ...(preset.loadConfig ? { loadConfig: preset.loadConfig } : {}), }), ); } diff --git a/studio/frontend/src/features/hub/catalog/models-header.tsx b/studio/frontend/src/features/hub/catalog/models-header.tsx index f0e0950871..f844336d4a 100644 --- a/studio/frontend/src/features/hub/catalog/models-header.tsx +++ b/studio/frontend/src/features/hub/catalog/models-header.tsx @@ -64,7 +64,7 @@ export function ModelsHeader({ return (
KV Cache Dtype Lower KV cache precision to save VRAM at the cost of some quality. - f16/bf16 are full precision; q8_0/q5_1/q4_1 are quantized. + f16 is the default; bf16 and f32 are full precision; q8_0 through + iq4_nl are quantized.