unslothai/unsloth/main 3.2M tokens More Tools
```
├── .gitattributes (omitted)
├── .github/
   ├── CODEOWNERS (600 tokens)
   ├── FUNDING.yml (100 tokens)
   ├── ISSUE_TEMPLATE/
      ├── bug---issue.md (100 tokens)
      ├── feature-request.md (100 tokens)
   ├── dependabot.yml (600 tokens)
   ├── workflows/
      ├── consolidated-tests-ci.yml (23k tokens)
      ├── lint-ci.yml (2.8k tokens)
      ├── mlx-ci.yml (4k tokens)
      ├── notebooks-ci.yml (4k tokens)
      ├── release-desktop.yml (8.1k tokens)
      ├── security-audit.yml (10.1k tokens)
      ├── stale.yml (300 tokens)
      ├── studio-api-smoke.yml (1200 tokens)
      ├── studio-backend-ci.yml (2.1k tokens)
      ├── studio-frontend-ci.yml (1100 tokens)
      ├── studio-inference-smoke.yml (7.9k tokens)
      ├── studio-mac-api-smoke.yml (1100 tokens)
      ├── studio-mac-inference-smoke.yml (9.8k tokens)
      ├── studio-mac-ui-smoke.yml (2.9k tokens)
      ├── studio-mac-update-smoke.yml (1200 tokens)
      ├── studio-tauri-smoke.yml (1000 tokens)
      ├── studio-ui-smoke.yml (2000 tokens)
      ├── studio-update-smoke.yml (1300 tokens)
      ├── studio-windows-api-smoke.yml (2000 tokens)
      ├── studio-windows-inference-smoke.yml (10.5k tokens)
      ├── studio-windows-ui-smoke.yml (2.8k tokens)
      ├── studio-windows-update-smoke.yml (2.4k tokens)
      ├── version-compat-ci.yml (2.5k tokens)
      ├── wheel-smoke.yml (1100 tokens)
├── .gitignore (900 tokens)
├── .pre-commit-ci.yaml
├── .pre-commit-config.yaml (100 tokens)
├── CODE_OF_CONDUCT.md (1100 tokens)
├── CONTRIBUTING.md (400 tokens)
├── COPYING (6.9k tokens)
├── LICENSE (omitted)
├── README.md (3.5k tokens)
├── build.sh (700 tokens)
├── cli.py
├── images/
   ├── Assistant.png
   ├── Colab.png
   ├── Discord button.png
   ├── Discord.png
   ├── Documentation Button.png
   ├── Free version button.png
   ├── Kaggle.png
   ├── Kofi button.png
   ├── LAION 2GPU.png
   ├── Merge.png
   ├── Run.png
   ├── STUDIO BLACK LOGO.png
   ├── STUDIO WHITE LOGO.png
   ├── Slim Orca 2GPUs.png
   ├── Terminal_Type.png
   ├── Where_Terminal.png
   ├── buy me a coffee button.png
   ├── documentation github button.png
   ├── documentation green button.png
   ├── documentation lighter.png
   ├── documentation white button.png
   ├── made with unsloth.png
   ├── ollama.png
   ├── peft x trl button.png
   ├── start free finetune button.png
   ├── unsloth end.png
   ├── unsloth loading page render.png
   ├── unsloth logo black text.png
   ├── unsloth logo only.png
   ├── unsloth logo white text.png
   ├── unsloth made with love.png
   ├── unsloth new logo.png
   ├── unsloth sticker.png
├── install.ps1 (16.3k tokens)
├── install.sh (18.5k tokens)
├── pyproject.toml (19.9k tokens)
├── scripts/
   ├── check_new_install_scripts.py (2.1k tokens)
   ├── data/
      ├── colab_apt_list.gpu.txt (16.5k tokens)
      ├── colab_os_info.gpu.txt (100 tokens)
      ├── colab_pip_freeze.gpu.txt (3k tokens)
      ├── colab_to_cpu_pin.json (300 tokens)
   ├── enforce_kwargs_spacing.py (1300 tokens)
   ├── install_gemma4_mlx.sh (1200 tokens)
   ├── install_qwen3_6_mlx.sh (1800 tokens)
   ├── lint_workflow_triggers.py (1200 tokens)
   ├── lockfile_supply_chain_audit.py (6.2k tokens)
   ├── notebook_to_python.py (2.8k tokens)
   ├── notebook_validator.py (9.7k tokens)
   ├── run_ruff_format.py (200 tokens)
   ├── scan_npm_packages.py (11.4k tokens)
   ├── scan_packages.py (16.1k tokens)
   ├── stamp_studio_release.py (1800 tokens)
   ├── verify_comment_only_diff.py (1700 tokens)
├── studio/
   ├── LICENSE.AGPL-3.0 (6.9k tokens)
   ├── Unsloth_Studio_Colab.ipynb (1200 tokens)
   ├── __init__.py
   ├── backend/
      ├── __init__.py
      ├── _platform_compat.py (400 tokens)
      ├── assets/
         ├── __init__.py
         ├── configs/
            ├── __init__.py
            ├── full_finetune.yaml (200 tokens)
            ├── inference_defaults.json (1700 tokens)
            ├── lora_text.yaml (200 tokens)
            ├── model_defaults/
               ├── default.yaml (200 tokens)
               ├── embedding/
                  ├── unsloth_Qwen3-Embedding-0.6B.yaml (200 tokens)
                  ├── unsloth_all-MiniLM-L6-v2.yaml (200 tokens)
                  ├── unsloth_bge-m3.yaml (200 tokens)
                  ├── unsloth_embeddinggemma-300m.yaml (200 tokens)
                  ├── unsloth_gte-modernbert-base.yaml (200 tokens)
               ├── ernie/
                  ├── unsloth_ERNIE-4.5-21B-A3B-PT.yaml (200 tokens)
                  ├── unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml (200 tokens)
               ├── falcon/
                  ├── tiiuae_Falcon-H1-0.5B-Instruct.yaml (200 tokens)
               ├── gemma/
                  ├── unsloth_codegemma-7b-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_functiongemma-270m-it.yaml (200 tokens)
                  ├── unsloth_gemma-2-27b-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_gemma-2-2b.yaml (200 tokens)
                  ├── unsloth_gemma-3-270m-it.yaml (200 tokens)
                  ├── unsloth_gemma-3-27b-it.yaml (200 tokens)
                  ├── unsloth_gemma-3-4b-it.yaml (200 tokens)
                  ├── unsloth_gemma-3-4b-pt.yaml (200 tokens)
                  ├── unsloth_gemma-3n-E4B-it.yaml (200 tokens)
                  ├── unsloth_gemma-3n-E4B.yaml (200 tokens)
                  ├── unsloth_gemma-4-26B-A4B-it.yaml (200 tokens)
                  ├── unsloth_gemma-4-26B-A4B.yaml (200 tokens)
                  ├── unsloth_gemma-4-31B-it.yaml (200 tokens)
                  ├── unsloth_gemma-4-31B.yaml (200 tokens)
                  ├── unsloth_gemma-4-E2B-it.yaml (200 tokens)
                  ├── unsloth_gemma-4-E2B.yaml (200 tokens)
                  ├── unsloth_gemma-4-E4B-it.yaml (200 tokens)
                  ├── unsloth_gemma-4-E4B.yaml (200 tokens)
               ├── gpt-oss/
                  ├── unsloth_gpt-oss-120b.yaml (200 tokens)
                  ├── unsloth_gpt-oss-20b.yaml (200 tokens)
               ├── granite/
                  ├── unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_granite-4.0-h-micro.yaml (200 tokens)
               ├── llama/
                  ├── unsloth_Llama-3.2-11B-Vision-Instruct.yaml (200 tokens)
                  ├── unsloth_Llama-3.2-1B-Instruct.yaml (200 tokens)
                  ├── unsloth_Llama-3.2-3B-Instruct.yaml (200 tokens)
                  ├── unsloth_Llama-3.3-70B-Instruct.yaml (300 tokens)
                  ├── unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_llama-3-8b-Instruct-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_llama-3-8b-bnb-4bit.yaml (200 tokens)
               ├── llasa/
                  ├── unsloth_Llasa-3B.yaml (200 tokens)
               ├── mistral/
                  ├── unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_Ministral-3-3B-Instruct-2512.yaml (200 tokens)
                  ├── unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_Mistral-Small-Instruct-2409.yaml (200 tokens)
                  ├── unsloth_Pixtral-12B-2409.yaml (200 tokens)
                  ├── unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_mistral-7b-v0.3-bnb-4bit.yaml (200 tokens)
               ├── other/
                  ├── OuteAI_Llama-OuteTTS-1.0-1B.yaml (200 tokens)
                  ├── Spark-TTS-0.5B_LLM.yaml (200 tokens)
                  ├── sesame_csm-1b.yaml (200 tokens)
                  ├── unsloth_GLM-4.7-Flash.yaml (200 tokens)
                  ├── unsloth_LFM2-1.2B.yaml (200 tokens)
                  ├── unsloth_Nemotron-3-Nano-30B-A3B.yaml (200 tokens)
                  ├── unsloth_PaddleOCR-VL.yaml (200 tokens)
                  ├── unsloth_answerdotai_ModernBERT-large.yaml (200 tokens)
                  ├── unsloth_orpheus-3b-0.1-ft.yaml (200 tokens)
                  ├── unsloth_tinyllama-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_whisper-large-v3.yaml (200 tokens)
               ├── phi/
                  ├── unsloth_Phi-3-medium-4k-instruct.yaml (200 tokens)
                  ├── unsloth_Phi-3.5-mini-instruct.yaml (200 tokens)
                  ├── unsloth_Phi-4.yaml (200 tokens)
               ├── qwen/
                  ├── imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml (200 tokens)
                  ├── unsloth_Qwen2-7B.yaml (200 tokens)
                  ├── unsloth_Qwen2-VL-7B-Instruct.yaml (200 tokens)
                  ├── unsloth_Qwen2.5-1.5B-Instruct.yaml (200 tokens)
                  ├── unsloth_Qwen2.5-7B.yaml (200 tokens)
                  ├── unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml (200 tokens)
                  ├── unsloth_Qwen2.5-Coder-14B-Instruct.yaml (200 tokens)
                  ├── unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_Qwen3-0.6B.yaml (200 tokens)
                  ├── unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml (200 tokens)
                  ├── unsloth_Qwen3-14B.yaml (200 tokens)
                  ├── unsloth_Qwen3-30B-A3B-Instruct-2507.yaml (200 tokens)
                  ├── unsloth_Qwen3-32B.yaml (200 tokens)
                  ├── unsloth_Qwen3-4B-Instruct-2507.yaml (200 tokens)
                  ├── unsloth_Qwen3-4B-Thinking-2507.yaml (200 tokens)
                  ├── unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml (200 tokens)
            ├── vision_lora.yaml (200 tokens)
         ├── datasets/
            ├── alpaca_unsloth.json (21.4k tokens)
      ├── auth/
         ├── .gitkeep
         ├── __init__.py (300 tokens)
         ├── authentication.py (1300 tokens)
         ├── hashing.py (200 tokens)
         ├── storage.py (4.9k tokens)
      ├── colab.py (800 tokens)
      ├── core/
         ├── __init__.py (900 tokens)
         ├── data_recipe/
            ├── __init__.py (100 tokens)
            ├── huggingface.py (900 tokens)
            ├── jobs/
               ├── __init__.py
               ├── constants.py (200 tokens)
               ├── manager.py (4.2k tokens)
               ├── parse.py (3.4k tokens)
               ├── types.py (600 tokens)
               ├── worker.py (1700 tokens)
            ├── jsonable.py (800 tokens)
            ├── local_callable_validators.py (2.2k tokens)
            ├── oxc-validator/
               ├── package.json
               ├── validate.mjs (3k tokens)
            ├── service.py (2.1k tokens)
         ├── export/
            ├── __init__.py (100 tokens)
            ├── export.py (6.8k tokens)
            ├── orchestrator.py (4.3k tokens)
            ├── worker.py (4k tokens)
         ├── inference/
            ├── __init__.py (200 tokens)
            ├── _html_to_md.py (2.9k tokens)
            ├── anthropic_compat.py (4.1k tokens)
            ├── audio_codecs.py (2.6k tokens)
            ├── defaults.py (300 tokens)
            ├── external_provider.py (29.5k tokens)
            ├── inference.py (17.2k tokens)
            ├── key_exchange.py (900 tokens)
            ├── llama_cpp.py (39.8k tokens)
            ├── llama_server_args.py (1000 tokens)
            ├── mlx_inference.py (2.9k tokens)
            ├── orchestrator.py (8.9k tokens)
            ├── providers.py (2.7k tokens)
            ├── tools.py (12.5k tokens)
            ├── worker.py (6.7k tokens)
         ├── training/
            ├── __init__.py (100 tokens)
            ├── resume.py (400 tokens)
            ├── trainer.py (31.6k tokens)
            ├── training.py (8k tokens)
            ├── worker.py (15.4k tokens)
      ├── loggers/
         ├── .gitkeep
         ├── __init__.py
         ├── config.py (700 tokens)
         ├── handlers.py (700 tokens)
      ├── main.py (5.1k tokens)
      ├── models/
         ├── .gitkeep
         ├── __init__.py (600 tokens)
         ├── auth.py (600 tokens)
         ├── data_recipe.py (1000 tokens)
         ├── datasets.py (700 tokens)
         ├── export.py (1100 tokens)
         ├── inference.py (8.6k tokens)
         ├── models.py (2k tokens)
         ├── providers.py (1000 tokens)
         ├── responses.py (500 tokens)
         ├── training.py (3.6k tokens)
         ├── users.py (200 tokens)
      ├── plugins/
         ├── __init__.py
         ├── data-designer-github-repo-seed/
            ├── README.md (600 tokens)
            ├── pyproject.toml (100 tokens)
            ├── src/
               ├── data_designer_github_repo_seed/
                  ├── __init__.py (100 tokens)
                  ├── config.py (400 tokens)
                  ├── impl.py (600 tokens)
                  ├── plugin.py (100 tokens)
                  ├── scraper.py (1700 tokens)
                  ├── scraper_impl/
                     ├── __init__.py
                     ├── gh_client.py (2.5k tokens)
                     ├── queries.py (5.1k tokens)
                     ├── scraper.py (5.8k tokens)
                     ├── state_store.py (700 tokens)
         ├── data-designer-unstructured-seed/
            ├── __init__.py
            ├── pyproject.toml (200 tokens)
            ├── src/
               ├── data_designer_unstructured_seed/
                  ├── __init__.py (100 tokens)
                  ├── chunking.py (1800 tokens)
                  ├── config.py (300 tokens)
                  ├── impl.py (300 tokens)
                  ├── plugin.py (100 tokens)
      ├── requirements/
         ├── __init__.py
         ├── base.txt
         ├── extras-no-deps.txt (100 tokens)
         ├── extras.txt (200 tokens)
         ├── no-torch-runtime.txt (400 tokens)
         ├── overrides.txt
         ├── single-env/
            ├── constraints.txt (100 tokens)
            ├── data-designer-deps.txt (100 tokens)
            ├── data-designer.txt
            ├── patch_metadata.py (400 tokens)
         ├── studio.txt (100 tokens)
         ├── triton-kernels.txt
      ├── routes/
         ├── .gitkeep
         ├── __init__.py (200 tokens)
         ├── auth.py (2k tokens)
         ├── data_recipe/
            ├── __init__.py (200 tokens)
            ├── jobs.py (4.4k tokens)
            ├── mcp.py (500 tokens)
            ├── seed.py (4.3k tokens)
            ├── validate.py (1400 tokens)
         ├── datasets.py (5k tokens)
         ├── export.py (3.6k tokens)
         ├── inference.py (39.2k tokens)
         ├── models.py (19.8k tokens)
         ├── providers.py (2.3k tokens)
         ├── training.py (7.1k tokens)
         ├── training_history.py (800 tokens)
      ├── run.py (3.6k tokens)
      ├── startup_banner.py (800 tokens)
      ├── state/
         ├── .gitkeep
         ├── __init__.py
         ├── tool_policy.py (200 tokens)
      ├── storage/
         ├── __init__.py
         ├── providers_db.py (900 tokens)
         ├── studio_db.py (3.9k tokens)
      ├── tests/
         ├── __init__.py
         ├── conftest.py (1000 tokens)
         ├── test_anthropic_code_execution.py (2.7k tokens)
         ├── test_anthropic_messages.py (7.1k tokens)
         ├── test_anthropic_thinking_translation.py (2.6k tokens)
         ├── test_browse_folders_route.py (500 tokens)
         ├── test_cache_case_resolution.py (800 tokens)
         ├── test_cached_gguf_routes.py (2.3k tokens)
         ├── test_data_recipe_github_progress.py (600 tokens)
         ├── test_data_recipe_seed.py (100 tokens)
         ├── test_desktop_auth.py (4.4k tokens)
         ├── test_detect_mmproj_file.py (2.4k tokens)
         ├── test_export_log_cursor.py (1400 tokens)
         ├── test_gguf_metadata.py (1400 tokens)
         ├── test_gpu_selection.py (10.6k tokens)
         ├── test_gpu_selection_sandbox.py (3.9k tokens)
         ├── test_host_defaults.py (800 tokens)
         ├── test_inference_model_validation.py (200 tokens)
         ├── test_kv_cache_estimation.py (16.3k tokens)
         ├── test_llama_cpp_cache_aware_disk_check.py (1700 tokens)
         ├── test_llama_cpp_context_fit.py (3.7k tokens)
         ├── test_llama_cpp_load_progress.py (1800 tokens)
         ├── test_llama_cpp_load_progress_live.py (1400 tokens)
         ├── test_llama_cpp_load_progress_matrix.py (3.5k tokens)
         ├── test_llama_cpp_max_context_threshold.py (1600 tokens)
         ├── test_llama_cpp_no_context_shift.py (1000 tokens)
         ├── test_llama_cpp_windows_nvidia_path.py (2.2k tokens)
         ├── test_llama_server_args.py (1200 tokens)
         ├── test_log_filter_no_truncation.py (1000 tokens)
         ├── test_middleware.py (1800 tokens)
         ├── test_mlx_inference_backend.py (900 tokens)
         ├── test_mlx_training_worker_config.py (500 tokens)
         ├── test_models_get_model_config_case_resolution.py (600 tokens)
         ├── test_native_context_length.py (4k tokens)
         ├── test_openai_code_execution.py (2.5k tokens)
         ├── test_openai_container_crud.py (1500 tokens)
         ├── test_openai_responses_translation.py (3.2k tokens)
         ├── test_openai_tool_passthrough.py (4.2k tokens)
         ├── test_providers_api.py (4.6k tokens)
         ├── test_pytorch_mirror.py (400 tokens)
         ├── test_responses_api.py (2.3k tokens)
         ├── test_responses_tool_passthrough.py (5k tokens)
         ├── test_sandbox_tools.py (1700 tokens)
         ├── test_studio_api.py (6.9k tokens)
         ├── test_studio_train_validation.py (500 tokens)
         ├── test_tool_policy_gates.py (400 tokens)
         ├── test_tool_policy_state.py (300 tokens)
         ├── test_trained_model_scan.py (700 tokens)
         ├── test_training_history_update.py (600 tokens)
         ├── test_training_raw_support.py (1500 tokens)
         ├── test_training_worker_flash_attn.py (1300 tokens)
         ├── test_transformers_version.py (2.7k tokens)
         ├── test_utils.py (2.5k tokens)
         ├── test_vision_cache.py (2.4k tokens)
         ├── test_vram_estimation.py (17.5k tokens)
      ├── utils/
         ├── .gitkeep
         ├── __init__.py
         ├── _studio_release_build.py (100 tokens)
         ├── cache_cleanup.py (700 tokens)
         ├── datasets/
            ├── __init__.py (600 tokens)
            ├── chat_templates.py (3.5k tokens)
            ├── data_collators.py (1200 tokens)
            ├── dataset_utils.py (9.3k tokens)
            ├── format_conversion.py (6.5k tokens)
            ├── format_detection.py (6k tokens)
            ├── llm_assist.py (6.9k tokens)
            ├── model_mappings.py (4.2k tokens)
            ├── raw_text.py (900 tokens)
            ├── vlm_processing.py (1700 tokens)
         ├── downsample.py (100 tokens)
         ├── hardware/
            ├── VRAM_ESTIMATION.md (1300 tokens)
            ├── __init__.py (500 tokens)
            ├── amd.py (2.9k tokens)
            ├── hardware.py (12.2k tokens)
            ├── nvidia.py (2k tokens)
            ├── vram_estimation.py (9.8k tokens)
         ├── inference/
            ├── __init__.py (100 tokens)
            ├── inference_config.py (1400 tokens)
         ├── models/
            ├── __init__.py (200 tokens)
            ├── checkpoints.py (1100 tokens)
            ├── gguf_metadata.py (1500 tokens)
            ├── model_config.py (17.8k tokens)
         ├── native_path_leases.py (3k tokens)
         ├── paths/
            ├── __init__.py (400 tokens)
            ├── path_utils.py (1500 tokens)
            ├── storage_roots.py (2.4k tokens)
         ├── studio_version.py (600 tokens)
         ├── subprocess_compat.py (300 tokens)
         ├── transformers_version.py (5k tokens)
         ├── update_status.py (2.4k tokens)
         ├── utils.py (700 tokens)
         ├── wheel_utils.py (1400 tokens)
   ├── frontend/
      ├── .gitignore (100 tokens)
      ├── .gitkeep
      ├── .npmrc (300 tokens)
      ├── biome.json (700 tokens)
      ├── components.json (100 tokens)
      ├── data-designer.openapi (1).yaml (18.6k tokens)
      ├── eslint.config.js (300 tokens)
      ├── index.html (100 tokens)
      ├── package-lock.json (119k tokens)
      ├── package.json (700 tokens)
      ├── public/
         ├── Hellix font official/
            ├── OTF/
               ├── Hellix-SemiBold.otf
            ├── TTF/
               ├── Hellix-SemiBold.ttf
            ├── WEB/
               ├── Hellix-SemiBold.woff
               ├── Hellix-SemiBold.woff2
         ├── Sloth emojis/
            ├── 241024 Sloth Drink Jus.png
            ├── 251008 Sloth Pin.png
            ├── FO2C6766BA42 Sloth Gift.png
            ├── FO71A40FA5581 Sloth and Llama.png
            ├── Large sloth Question mark.png
            ├── Sloth loca pc.png
            ├── Sloth w Gameboy Confetti no Logo.png
            ├── Sloth w PC Confetti no Logo.png
            ├── Sloth w PC no Logo.png
            ├── UnSloth Eat GPU Mouth.png
            ├── UnSloth Eat GPU.png
            ├── UnSloth GPU Front square.png
            ├── UnSloth Laptop.png
            ├── UnSloth Sparkling large.png
            ├── large sloth cheeky.png
            ├── large sloth drink.png
            ├── large sloth fire.png
            ├── large sloth glasses.png
            ├── large sloth heart.png
            ├── large sloth laugh.png
            ├── large sloth sad.png
            ├── large sloth thumbs.png
            ├── large sloth wave.png
            ├── large sloth yay.png
            ├── sloth headphones.png
            ├── sloth huglove large.png
            ├── sloth huglove large33.png
            ├── sloth magnify final(1).png
            ├── sloth magnify final.png
            ├── sloth on phone.png
            ├── sloth pc emoji.png
            ├── sloth pc square.png
            ├── sloth rounded.png
            ├── sloth shock large.png
            ├── sloth shy large.png
            ├── sloth sir large.png
            ├── sloth w pc transparent.png
            ├── sloth with gameboy.png
         ├── circle-logo-small.png
         ├── favicon.png
         ├── fonts/
            ├── FiraCode-VariableFont_wght.ttf
            ├── Hellix-Medium.woff
            ├── Hellix-Regular.woff
            ├── Hellix-SemiBold.woff
            ├── Hellix-SemiBold.woff2
         ├── huggingface.svg (900 tokens)
         ├── logotext.png
         ├── provider-logos/
            ├── anthropic.svg (1000 tokens)
            ├── deepseek.svg (500 tokens)
            ├── gemini.svg (629.4k tokens)
            ├── huggingface.svg (7.1k tokens)
            ├── kimi.jpg
            ├── llama_cpp.svg (200 tokens)
            ├── misc/
               ├── meta.svg (500 tokens)
               ├── microsoft.svg (100 tokens)
               ├── minimax.png
               ├── nvidia.svg (200 tokens)
               ├── perplexity.png
               ├── xai.svg (100 tokens)
               ├── z-ai.svg (2.2k tokens)
            ├── mistral.svg (200 tokens)
            ├── ollama.svg (1800 tokens)
            ├── openai.svg (500 tokens)
            ├── openrouter.svg (200 tokens)
            ├── qwen.png
            ├── vllm.svg (700 tokens)
         ├── rounded-512.png
         ├── rounded.png
         ├── sticker.png
         ├── studio github landscape colab display.png
         ├── studio.png
         ├── unsloth-gem.png
         ├── unsloth.ico
         ├── vite.svg (300 tokens)
      ├── src/
         ├── app/
            ├── app.tsx (100 tokens)
            ├── auth-guards.ts (500 tokens)
            ├── provider.tsx (1800 tokens)
            ├── router.tsx (200 tokens)
            ├── routes/
               ├── __root.tsx (800 tokens)
               ├── change-password.tsx (100 tokens)
               ├── chat.tsx (200 tokens)
               ├── data-recipes.$recipeId.tsx (200 tokens)
               ├── data-recipes.tsx (100 tokens)
               ├── export.tsx (100 tokens)
               ├── grid-test.tsx (400 tokens)
               ├── index.tsx (100 tokens)
               ├── login.tsx (100 tokens)
               ├── onboarding.tsx (200 tokens)
               ├── studio.tsx (100 tokens)
         ├── assets/
            ├── react.svg (900 tokens)
         ├── components/
            ├── app-sidebar.tsx (6.9k tokens)
            ├── assistant-ui/
               ├── attachment.tsx (1400 tokens)
               ├── audio-player.tsx (600 tokens)
               ├── badge.tsx (400 tokens)
               ├── code-plugin.ts (400 tokens)
               ├── code-themes.ts (200 tokens)
               ├── code-toggle-icon.tsx (100 tokens)
               ├── markdown-text.tsx (2.8k tokens)
               ├── message-timing.tsx (1300 tokens)
               ├── model-selector.tsx (3.6k tokens)
               ├── model-selector/
                  ├── folder-browser.tsx (2.6k tokens)
                  ├── model-delete-action.tsx (600 tokens)
                  ├── pickers.tsx (12.8k tokens)
                  ├── types.ts (200 tokens)
               ├── reasoning.tsx (2.6k tokens)
               ├── sources.tsx (1800 tokens)
               ├── thread.tsx (8.8k tokens)
               ├── tool-fallback.tsx (1900 tokens)
               ├── tool-group.tsx (1400 tokens)
               ├── tool-ui-code-execution.tsx (900 tokens)
               ├── tool-ui-python.tsx (1200 tokens)
               ├── tool-ui-terminal.tsx (700 tokens)
               ├── tool-ui-web-search.tsx (900 tokens)
               ├── tooltip-icon-button.tsx (300 tokens)
               ├── use-intent-aware-autoscroll.tsx (4.5k tokens)
            ├── example.tsx (300 tokens)
            ├── layout/
               ├── dashboard-grid.tsx (100 tokens)
               ├── dashboard-layout.tsx (100 tokens)
               ├── index.ts
            ├── markdown/
               ├── markdown-preview.tsx (300 tokens)
               ├── mermaid-error.tsx (200 tokens)
            ├── navbar.tsx (100 tokens)
            ├── section-card.tsx (600 tokens)
            ├── shutdown-dialog.tsx (600 tokens)
            ├── tauri/
               ├── startup-screen.tsx (2.7k tokens)
               ├── update-banner.tsx (1200 tokens)
               ├── update-screen.tsx (1400 tokens)
               ├── window-titlebar.tsx (1900 tokens)
            ├── ui/
               ├── accordion.tsx (600 tokens)
               ├── alert-dialog.tsx (1200 tokens)
               ├── alert.tsx (500 tokens)
               ├── animated-shiny-text.tsx (200 tokens)
               ├── animated-theme-toggler.tsx (800 tokens)
               ├── aspect-ratio.tsx (100 tokens)
               ├── avatar.tsx (700 tokens)
               ├── badge.tsx (400 tokens)
               ├── breadcrumb.tsx (600 tokens)
               ├── button.tsx (600 tokens)
               ├── calendar.tsx (1800 tokens)
               ├── card.tsx (600 tokens)
               ├── chart.tsx (2.7k tokens)
               ├── checkbox.tsx (300 tokens)
               ├── collapsible.tsx (300 tokens)
               ├── combobox.tsx (2.3k tokens)
               ├── command.tsx (1100 tokens)
               ├── confetti.tsx (700 tokens)
               ├── context-menu.tsx (1800 tokens)
               ├── data-table.tsx (700 tokens)
               ├── dialog.tsx (1100 tokens)
               ├── dropdown-menu.tsx (1900 tokens)
               ├── empty.tsx (500 tokens)
               ├── field.tsx (1300 tokens)
               ├── hover-card.tsx (300 tokens)
               ├── input-group.tsx (1100 tokens)
               ├── input.tsx (200 tokens)
               ├── label.tsx (200 tokens)
               ├── light-rays.tsx (800 tokens)
               ├── menubar.tsx (1800 tokens)
               ├── navigation-menu.tsx (1400 tokens)
               ├── pagination.tsx (700 tokens)
               ├── popover.tsx (500 tokens)
               ├── progress.tsx (200 tokens)
               ├── radio-group.tsx (400 tokens)
               ├── resizable.tsx (400 tokens)
               ├── scroll-area.tsx (400 tokens)
               ├── select.tsx (1700 tokens)
               ├── separator.tsx (200 tokens)
               ├── sheet.tsx (1100 tokens)
               ├── shimmer-button.tsx (600 tokens)
               ├── shine-border.tsx (400 tokens)
               ├── sidebar.tsx (4.8k tokens)
               ├── skeleton.tsx (100 tokens)
               ├── slider.tsx (700 tokens)
               ├── sonner.tsx (400 tokens)
               ├── sparkles-text.tsx (900 tokens)
               ├── spinner.tsx (100 tokens)
               ├── switch.tsx (400 tokens)
               ├── table.tsx (500 tokens)
               ├── tabs.tsx (1000 tokens)
               ├── terminal.tsx (1000 tokens)
               ├── textarea.tsx (200 tokens)
               ├── toggle-group.tsx (700 tokens)
               ├── toggle.tsx (400 tokens)
               ├── tooltip.tsx (700 tokens)
            ├── web/
               ├── update-banner.tsx (900 tokens)
         ├── config/
            ├── env.ts (500 tokens)
            ├── training.ts (1000 tokens)
         ├── features/
            ├── auth/
               ├── api.ts (900 tokens)
               ├── change-password-page.tsx (200 tokens)
               ├── components/
                  ├── auth-form.tsx (2.8k tokens)
               ├── index.ts (100 tokens)
               ├── login-page.tsx (200 tokens)
               ├── session.ts (600 tokens)
               ├── tauri-auto-auth.ts (700 tokens)
            ├── chat/
               ├── api-provider-logo.tsx (400 tokens)
               ├── api/
                  ├── chat-adapter.ts (12.7k tokens)
                  ├── chat-api.ts (2.7k tokens)
                  ├── openai-containers.ts (800 tokens)
                  ├── providers-api.ts (1500 tokens)
               ├── chat-page.tsx (10.2k tokens)
               ├── chat-providers-dialog.tsx (11.4k tokens)
               ├── chat-settings-sheet.tsx (11.6k tokens)
               ├── components/
                  ├── chat-search-dialog.tsx (900 tokens)
                  ├── context-usage-bar.tsx (900 tokens)
                  ├── model-load-status.tsx (1100 tokens)
                  ├── openai-code-exec-section.tsx (5k tokens)
               ├── db.ts (400 tokens)
               ├── external-providers.ts (2.5k tokens)
               ├── hooks/
                  ├── use-chat-model-runtime.ts (8.5k tokens)
                  ├── use-chat-search-index.ts (900 tokens)
                  ├── use-chat-sidebar-items.ts (700 tokens)
                  ├── use-transfer-stats.ts (400 tokens)
               ├── index.ts (200 tokens)
               ├── lib/
                  ├── friendly-names.ts (900 tokens)
                  ├── training-compare-handoff.ts (300 tokens)
               ├── presets/
                  ├── preset-policy.ts (1700 tokens)
               ├── provider-capabilities.ts (4.2k tokens)
               ├── runtime-provider.tsx (5.5k tokens)
               ├── shared-composer.tsx (7.6k tokens)
               ├── stores/
                  ├── chat-runtime-store.ts (3.5k tokens)
                  ├── chat-search-store.ts (100 tokens)
                  ├── external-providers-store.ts (100 tokens)
               ├── thread-sidebar.tsx (1000 tokens)
               ├── tour/
                  ├── index.ts
                  ├── steps.tsx (500 tokens)
               ├── types.ts (300 tokens)
               ├── types/
                  ├── api.ts (1300 tokens)
                  ├── runtime.ts (200 tokens)
               ├── utils/
                  ├── chat-thread-tombstones.ts (100 tokens)
                  ├── clear-all-chats.ts (100 tokens)
                  ├── delete-thread-message.ts (900 tokens)
                  ├── export-chat-history.ts (200 tokens)
                  ├── format-transfer.ts (300 tokens)
                  ├── parse-assistant-content.ts (400 tokens)
                  ├── qwen-params.ts (200 tokens)
                  ├── transfer-stats.ts (600 tokens)
            ├── data-recipes/
               ├── data/
                  ├── recipes-db.ts (600 tokens)
               ├── hooks/
                  ├── use-recipe-sidebar-items.ts (100 tokens)
               ├── index.ts (100 tokens)
               ├── learning-recipes/
                  ├── conversation.json (1700 tokens)
                  ├── github-support-bot.json (2.1k tokens)
                  ├── index.ts (1000 tokens)
                  ├── instruction-from-answer.json (900 tokens)
                  ├── ocr-document-extraction.json (900 tokens)
                  ├── pdf-grounded-qa.json (1000 tokens)
                  ├── structured-outputs-jinja.json (2.2k tokens)
                  ├── text-to-python.json (1500 tokens)
                  ├── text-to-sql.json (1900 tokens)
               ├── pages/
                  ├── data-recipes-page.tsx (3.8k tokens)
                  ├── edit-recipe-page.tsx (700 tokens)
               ├── types.ts (100 tokens)
            ├── export/
               ├── anim.ts (100 tokens)
               ├── api/
                  ├── export-api.ts (1800 tokens)
               ├── components/
                  ├── export-dialog.tsx (4.8k tokens)
                  ├── method-picker.tsx (1100 tokens)
                  ├── quant-picker.tsx (800 tokens)
               ├── constants.ts (500 tokens)
               ├── export-page.tsx (9k tokens)
               ├── index.ts
               ├── tour/
                  ├── index.ts
                  ├── steps.tsx (300 tokens)
            ├── native-intents/
               ├── api.ts (300 tokens)
               ├── components/
                  ├── native-model-chip.tsx (700 tokens)
                  ├── native-model-drop-overlay.tsx (500 tokens)
               ├── native-intent-drain.tsx (200 tokens)
               ├── store.ts (200 tokens)
               ├── types.ts (200 tokens)
               ├── use-native-dialogs.ts (400 tokens)
               ├── use-native-drop.ts (800 tokens)
               ├── use-native-readiness.ts (200 tokens)
            ├── onboarding/
               ├── components/
                  ├── splash-screen.tsx (500 tokens)
                  ├── steps/
                     ├── dataset-step.tsx (2.7k tokens)
                     ├── hyperparameters-step.tsx (3.6k tokens)
                     ├── model-selection-step.tsx (2.6k tokens)
                     ├── model-type-step.tsx (1900 tokens)
                     ├── summary-step.tsx (1600 tokens)
                  ├── wizard-content.tsx (400 tokens)
                  ├── wizard-footer.tsx (600 tokens)
                  ├── wizard-layout.tsx (800 tokens)
                  ├── wizard-sidebar.tsx (500 tokens)
                  ├── wizard-step-item.tsx (400 tokens)
               ├── index.ts
            ├── profile/
               ├── components/
                  ├── profile-personalization-panel.tsx (1200 tokens)
                  ├── user-avatar.tsx (200 tokens)
               ├── hooks/
                  ├── use-effective-profile.ts (100 tokens)
               ├── index.ts (100 tokens)
               ├── stores/
                  ├── user-profile-store.ts (200 tokens)
               ├── utils/
                  ├── avatar-initials.ts (100 tokens)
                  ├── jwt-subject.ts (200 tokens)
                  ├── resize-image-file.ts (500 tokens)
            ├── recipe-studio/
               ├── api/
                  ├── index.ts (2.7k tokens)
               ├── blocks/
                  ├── definitions.ts (2.4k tokens)
                  ├── registry.ts (100 tokens)
                  ├── render-dialog.tsx (1100 tokens)
               ├── components/
                  ├── block-sheet.tsx (5.1k tokens)
                  ├── chip-input.tsx (800 tokens)
                  ├── controls/
                     ├── layout-controls.tsx (400 tokens)
                     ├── run-validate-floating-controls.tsx (300 tokens)
                     ├── viewport-controls.tsx (500 tokens)
                  ├── executions/
                     ├── execution-columns-tab.tsx (400 tokens)
                     ├── execution-data-tab.tsx (2000 tokens)
                     ├── execution-overview-tab.tsx (2.5k tokens)
                     ├── execution-raw-tab.tsx (100 tokens)
                     ├── execution-sidebar.tsx (700 tokens)
                     ├── executions-view-helpers.ts (1100 tokens)
                     ├── executions-view.tsx (4.6k tokens)
                     ├── publish-execution-dialog.tsx (2.5k tokens)
                  ├── graph/
                     ├── internals-sync.tsx (200 tokens)
                  ├── inline/
                     ├── inline-category-badges.tsx (600 tokens)
                     ├── inline-expression.tsx (500 tokens)
                     ├── inline-field.tsx (100 tokens)
                     ├── inline-llm.tsx (1200 tokens)
                     ├── inline-model.tsx (800 tokens)
                     ├── inline-policy.ts (200 tokens)
                     ├── inline-sampler.tsx (900 tokens)
                     ├── inline-seed.tsx (1000 tokens)
                  ├── recipe-floating-icon-button-class.ts (100 tokens)
                  ├── recipe-graph-aux-node.tsx (1800 tokens)
                  ├── recipe-graph-node.tsx (4.2k tokens)
                  ├── recipe-graph-semantic-edge.tsx (200 tokens)
                  ├── recipe-studio-header.tsx (1400 tokens)
                  ├── rf-ui/
                     ├── base-handle.tsx (200 tokens)
                     ├── base-node.tsx (400 tokens)
                     ├── data-edge.tsx (500 tokens)
                     ├── labeled-handle.tsx (200 tokens)
                  ├── runtime/
                     ├── execution-progress-island.tsx (1700 tokens)
                  ├── shared/
                     ├── available-references-inline.tsx (900 tokens)
                     ├── hf-dataset-combobox.tsx (700 tokens)
               ├── constants.ts (100 tokens)
               ├── data/
                  ├── executions-db.ts (200 tokens)
               ├── dialogs/
                  ├── config-dialog.tsx (900 tokens)
                  ├── expression/
                     ├── expression-dialog.tsx (700 tokens)
                  ├── import-dialog.tsx (500 tokens)
                  ├── llm/
                     ├── general-tab.tsx (3.7k tokens)
                     ├── llm-dialog.tsx (400 tokens)
                     ├── scores-tab.tsx (1200 tokens)
                  ├── markdown-note/
                     ├── markdown-note-dialog.tsx (500 tokens)
                  ├── models/
                     ├── model-config-dialog.tsx (1900 tokens)
                     ├── model-provider-dialog.tsx (1500 tokens)
                  ├── preview-dialog.tsx (4.6k tokens)
                  ├── processors-dialog.tsx (1000 tokens)
                  ├── samplers/
                     ├── bernoulli-dialog.tsx (300 tokens)
                     ├── category-dialog.tsx (2.2k tokens)
                     ├── datetime-dialog.tsx (600 tokens)
                     ├── gaussian-dialog.tsx (600 tokens)
                     ├── person-dialog.tsx (800 tokens)
                     ├── subcategory-dialog.tsx (1000 tokens)
                     ├── timedelta-dialog.tsx (800 tokens)
                     ├── uniform-dialog.tsx (600 tokens)
                     ├── uuid-dialog.tsx (300 tokens)
                  ├── seed/
                     ├── seed-dialog.tsx (9.4k tokens)
                     ├── unstructured-drop-zone.tsx (1600 tokens)
                  ├── shared/
                     ├── available-variables.tsx (700 tokens)
                     ├── collapsible-section-trigger.tsx (300 tokens)
                     ├── dialog-shell.tsx (100 tokens)
                     ├── field-label.tsx (300 tokens)
                     ├── name-field.tsx (200 tokens)
                     ├── validation-banner.tsx (100 tokens)
                  ├── tool-profile/
                     ├── helpers.ts (400 tokens)
                     ├── tool-profile-dialog.tsx (6.1k tokens)
                  ├── validators/
                     ├── validator-dialog.tsx (1800 tokens)
               ├── easy/
                  ├── github-crawler-easy-view.tsx (1400 tokens)
               ├── execution-types.ts (700 tokens)
               ├── executions/
                  ├── execution-helpers.ts (1000 tokens)
                  ├── hydration.ts (200 tokens)
                  ├── run-settings.ts (1100 tokens)
                  ├── runtime.ts (1000 tokens)
                  ├── tracker.ts (1900 tokens)
               ├── hooks/
                  ├── use-node-connection-status.ts (300 tokens)
                  ├── use-recipe-editor-graph.ts (2.1k tokens)
                  ├── use-recipe-executions.ts (3.9k tokens)
                  ├── use-recipe-persistence.ts (1800 tokens)
                  ├── use-recipe-runtime-visuals.ts (900 tokens)
                  ├── use-recipe-studio-actions.ts (1100 tokens)
               ├── index.ts (100 tokens)
               ├── recipe-studio-page.tsx (6k tokens)
               ├── stores/
                  ├── helpers/
                     ├── edge-sync.ts (1800 tokens)
                     ├── model-infra-layout.ts (2.9k tokens)
                     ├── node-updates.ts (500 tokens)
                     ├── reference-sync.ts (1200 tokens)
                     ├── removals.ts (500 tokens)
                  ├── recipe-executions.ts (900 tokens)
                  ├── recipe-studio-helpers.ts (100 tokens)
                  ├── recipe-studio.ts (4.8k tokens)
               ├── types/
                  ├── index.ts (2.3k tokens)
               ├── utils/
                  ├── config-factories.ts (2.1k tokens)
                  ├── config-labels.ts (200 tokens)
                  ├── config-type-guards.ts (300 tokens)
                  ├── graph-warnings.ts (1100 tokens)
                  ├── graph.ts
                  ├── graph/
                     ├── derive-display-graph.ts (3.4k tokens)
                     ├── fit-view.ts (400 tokens)
                     ├── recipe-graph-connection.ts (2.7k tokens)
                     ├── relations.ts (200 tokens)
                     ├── runtime-visual-state.ts (1400 tokens)
                  ├── handle-layout.ts (100 tokens)
                  ├── handles.ts (1500 tokens)
                  ├── image-preview.ts (1100 tokens)
                  ├── import/
                     ├── edges.ts (1100 tokens)
                     ├── helpers.ts (300 tokens)
                     ├── importer.ts (3.7k tokens)
                     ├── index.ts (100 tokens)
                     ├── parsers.ts (300 tokens)
                     ├── parsers/
                        ├── expression-parser.ts (200 tokens)
                        ├── llm-parser.ts (700 tokens)
                        ├── model-parser.ts (500 tokens)
                        ├── sampler-parser.ts (1700 tokens)
                        ├── seed-config-parser.ts (1700 tokens)
                        ├── validator-parser.ts (500 tokens)
                     ├── types.ts (100 tokens)
                     ├── ui.ts (800 tokens)
                  ├── index.ts (200 tokens)
                  ├── layout.ts (1100 tokens)
                  ├── naming.ts (100 tokens)
                  ├── node-data.ts (600 tokens)
                  ├── parse.ts (300 tokens)
                  ├── payload/
                     ├── build-payload.ts (3k tokens)
                     ├── builders-llm.ts (1600 tokens)
                     ├── builders-model.ts (700 tokens)
                     ├── builders-processors.ts (300 tokens)
                     ├── builders-sampler.ts (1400 tokens)
                     ├── builders-seed.ts (1400 tokens)
                     ├── builders-validator.ts (600 tokens)
                     ├── builders.ts (100 tokens)
                     ├── empty.ts (200 tokens)
                     ├── index.ts (100 tokens)
                     ├── parse.ts
                     ├── types.ts (700 tokens)
                     ├── validate.ts (1200 tokens)
                  ├── processors.ts (100 tokens)
                  ├── reactflow-changes.ts (200 tokens)
                  ├── recipe-studio-view.ts (300 tokens)
                  ├── refs.ts (500 tokens)
                  ├── rf-node-dimensions.ts (200 tokens)
                  ├── ui-tones.ts (500 tokens)
                  ├── validation.ts (2.8k tokens)
                  ├── validators/
                     ├── code-lang.ts (300 tokens)
                     ├── oxc-code-shape.ts (100 tokens)
                     ├── oxc-mode.ts (100 tokens)
                  ├── variables.ts (500 tokens)
            ├── settings/
               ├── api/
                  ├── api-keys.ts (300 tokens)
               ├── components/
                  ├── api-key-row.tsx (700 tokens)
                  ├── create-key-form.tsx (500 tokens)
                  ├── key-reveal-card.tsx (500 tokens)
                  ├── settings-row.tsx (200 tokens)
                  ├── settings-section.tsx (200 tokens)
                  ├── theme-segmented.tsx (400 tokens)
                  ├── update-studio-instructions.tsx (2.4k tokens)
                  ├── usage-examples.tsx (1100 tokens)
               ├── index.ts (100 tokens)
               ├── settings-dialog.tsx (1400 tokens)
               ├── stores/
                  ├── settings-dialog-store.ts (300 tokens)
                  ├── theme-store.ts (600 tokens)
               ├── tabs/
                  ├── about-tab.tsx (1400 tokens)
                  ├── api-keys-tab.tsx (1100 tokens)
                  ├── appearance-tab.tsx (300 tokens)
                  ├── chat-tab.tsx (800 tokens)
                  ├── connections-tab.tsx (100 tokens)
                  ├── general-tab.tsx (1700 tokens)
                  ├── profile-tab.tsx (100 tokens)
            ├── studio/
               ├── historical-training-view.tsx (1200 tokens)
               ├── history-card-grid.tsx (3.4k tokens)
               ├── index.ts
               ├── live-training-view.tsx (800 tokens)
               ├── sections/
                  ├── charts-content.tsx (1800 tokens)
                  ├── charts-section.tsx (400 tokens)
                  ├── charts/
                     ├── chart-preferences-store.ts (600 tokens)
                     ├── chart-settings-sheet.tsx (2000 tokens)
                     ├── eval-loss-chart-card.tsx (1200 tokens)
                     ├── grad-norm-chart-card.tsx (800 tokens)
                     ├── learning-rate-chart-card.tsx (800 tokens)
                     ├── training-loss-chart-card.tsx (1100 tokens)
                     ├── types.ts (100 tokens)
                     ├── utils.ts (1000 tokens)
                  ├── dataset-preview-dialog-mapping.tsx (2.3k tokens)
                  ├── dataset-preview-dialog-utils.ts (300 tokens)
                  ├── dataset-preview-dialog.tsx (3.8k tokens)
                  ├── dataset-section.tsx (8.6k tokens)
                  ├── document-upload-redirect-dialog.tsx (600 tokens)
                  ├── model-section.tsx (5.1k tokens)
                  ├── params-section.tsx (7.8k tokens)
                  ├── progress-section-lib.ts (400 tokens)
                  ├── progress-section.tsx (4.1k tokens)
                  ├── training-section.tsx (1700 tokens)
               ├── studio-page.tsx (1800 tokens)
               ├── tour/
                  ├── index.ts
                  ├── steps/
                     ├── base-model.tsx (100 tokens)
                     ├── dataset.tsx (200 tokens)
                     ├── index.tsx (200 tokens)
                     ├── local-model.tsx (100 tokens)
                     ├── method.tsx (100 tokens)
                     ├── nav.tsx (100 tokens)
                     ├── params.tsx (100 tokens)
                     ├── save.tsx (100 tokens)
                     ├── start.tsx (100 tokens)
                  ├── training/
                     ├── index.ts
                     ├── steps.tsx (400 tokens)
               ├── training-start-overlay.tsx (2.8k tokens)
            ├── tour/
               ├── components/
                  ├── guided-tour.tsx (3k tokens)
                  ├── read-more.tsx (100 tokens)
                  ├── spotlight-overlay.tsx (300 tokens)
               ├── hooks/
                  ├── use-guided-tour-controller.ts (400 tokens)
               ├── index.ts (100 tokens)
               ├── lib/
                  ├── confetti-fireworks.ts (300 tokens)
                  ├── dom.ts (100 tokens)
                  ├── layout.ts (400 tokens)
               ├── types.ts (100 tokens)
            ├── training/
               ├── api/
                  ├── datasets-api.ts (700 tokens)
                  ├── history-api.ts (400 tokens)
                  ├── mappers.ts (1000 tokens)
                  ├── models-api.ts (900 tokens)
                  ├── train-api.ts (1200 tokens)
               ├── components/
                  ├── hf-dataset-subset-split-selectors.tsx (1900 tokens)
               ├── events.ts (300 tokens)
               ├── hooks/
                  ├── use-max-steps-epochs-toggle.ts (600 tokens)
                  ├── use-training-actions.ts (2.1k tokens)
                  ├── use-training-history-sidebar.ts (1100 tokens)
                  ├── use-training-runtime-lifecycle.ts (1700 tokens)
                  ├── use-training-unload-guard.ts (300 tokens)
               ├── index.ts (400 tokens)
               ├── lib/
                  ├── model-defaults.ts (1400 tokens)
                  ├── sync-runtime.ts (200 tokens)
                  ├── training-methods.ts (300 tokens)
                  ├── validation.ts (200 tokens)
                  ├── yaml-config.ts (600 tokens)
               ├── stores/
                  ├── dataset-preview-dialog-store.ts (200 tokens)
                  ├── training-config-store.ts (6k tokens)
                  ├── training-runtime-store.ts (2k tokens)
               ├── types/
                  ├── api.ts (400 tokens)
                  ├── config.ts (1100 tokens)
                  ├── datasets.ts (200 tokens)
                  ├── history.ts (300 tokens)
                  ├── runtime.ts (900 tokens)
         ├── hooks/
            ├── index.ts (200 tokens)
            ├── use-collapse-scroll-lock.ts (400 tokens)
            ├── use-debounced-value.ts (100 tokens)
            ├── use-gpu-info.ts (400 tokens)
            ├── use-gpu-utilization.ts (500 tokens)
            ├── use-hardware-info.ts (500 tokens)
            ├── use-hf-dataset-search.ts (2.2k tokens)
            ├── use-hf-dataset-splits.ts (1200 tokens)
            ├── use-hf-model-search.ts (2.3k tokens)
            ├── use-hf-paginated-search.ts (700 tokens)
            ├── use-hf-token-validation.ts (400 tokens)
            ├── use-infinite-scroll.ts (200 tokens)
            ├── use-mobile.ts (200 tokens)
            ├── use-recommended-model-vram.ts (400 tokens)
            ├── use-sidebar-pin.ts (300 tokens)
            ├── use-tauri-backend.ts (4k tokens)
            ├── use-tauri-update.ts (2.4k tokens)
            ├── use-web-update-check.ts (800 tokens)
         ├── index.css (8.1k tokens)
         ├── lib/
            ├── api-base.ts (200 tokens)
            ├── audio-utils.ts (100 tokens)
            ├── copy-to-clipboard.ts (400 tokens)
            ├── hf-cache.ts (1000 tokens)
            ├── latex.ts (1500 tokens)
            ├── native-notifications.ts (1300 tokens)
            ├── open-link.ts (200 tokens)
            ├── tauri-diagnostics.ts (1100 tokens)
            ├── utils.ts (100 tokens)
            ├── vram.ts (900 tokens)
         ├── main.tsx (300 tokens)
         ├── shared/
            ├── toast.ts (100 tokens)
         ├── speech-recognition.d.ts (omitted)
         ├── stores/
            ├── index.ts
            ├── training.ts (100 tokens)
         ├── types/
            ├── index.ts
            ├── training.ts (800 tokens)
         ├── utils/
            ├── index.ts
            ├── strings.ts (100 tokens)
      ├── tsconfig.app.json (200 tokens)
      ├── tsconfig.json
      ├── tsconfig.node.json (100 tokens)
      ├── vite.config.ts (300 tokens)
   ├── install_llama_prebuilt.py (42.6k tokens)
   ├── install_python_stack.py (8.7k tokens)
   ├── setup.bat
   ├── setup.ps1 (24.3k tokens)
   ├── setup.sh (10k tokens)
   ├── src-tauri/
      ├── Cargo.lock (omitted)
      ├── Cargo.toml (300 tokens)
      ├── Entitlements.plist (100 tokens)
      ├── build.rs
      ├── capabilities/
         ├── default.json (200 tokens)
      ├── icons/
         ├── 128x128.png
         ├── 32x32.png
         ├── icon.icns
         ├── icon.ico
         ├── icon.png
      ├── linux/
         ├── postremove.sh
      ├── src/
         ├── commands.rs (6k tokens)
         ├── desktop_auth.rs (3k tokens)
         ├── desktop_backend_owner.rs (6.4k tokens)
         ├── desktop_update_policy.rs (2.7k tokens)
         ├── diagnostics/
            ├── mod.rs (1100 tokens)
            ├── phase_log.rs (5.3k tokens)
            ├── redaction.rs (1500 tokens)
            ├── report.rs (4.4k tokens)
            ├── state.rs (5k tokens)
         ├── install.rs (6.1k tokens)
         ├── main.rs (1800 tokens)
         ├── native_backend_lease.rs (1100 tokens)
         ├── native_intents.rs (3.1k tokens)
         ├── native_path_policy.rs (1900 tokens)
         ├── preflight.rs (5.4k tokens)
         ├── preflight/
            ├── backend.rs (2.2k tokens)
            ├── managed.rs (1100 tokens)
            ├── types.rs (200 tokens)
            ├── version.rs (1100 tokens)
         ├── process.rs (8.5k tokens)
         ├── update.rs (2.6k tokens)
         ├── windows_job.rs (400 tokens)
      ├── tauri.conf.json (500 tokens)
      ├── tauri.macos.conf.json
      ├── tauri.windows.conf.json
      ├── windows/
         ├── branding/
            ├── nsis-header.bmp
            ├── nsis-sidebar.bmp
         ├── hooks.nsh (100 tokens)
         ├── installer.nsi (6.5k tokens)
├── tests/
   ├── __init__.py
   ├── _zoo_aggressive_cuda_spoof.py (1700 tokens)
   ├── conftest.py (1200 tokens)
   ├── notebooks/
      ├── __init__.py
      ├── test_validator_fixtures.py (2000 tokens)
   ├── python/
      ├── __init__.py
      ├── conftest.py (100 tokens)
      ├── test_cross_platform_parity.py (1100 tokens)
      ├── test_dpo_vision_processor_passthrough.py (800 tokens)
      ├── test_e2e_no_torch_sandbox.py (9.4k tokens)
      ├── test_fast_sentence_transformer_redirect_lifecycle.py (1700 tokens)
      ├── test_flash_attn_install_python_stack.py (3.7k tokens)
      ├── test_gpu_init_ldconfig_guard.py (300 tokens)
      ├── test_install_python_stack.py (400 tokens)
      ├── test_no_torch_filtering.py (6.1k tokens)
      ├── test_patch_trl_rl_trainers_defensive.py (400 tokens)
      ├── test_studio_import_no_torch.py (4.8k tokens)
      ├── test_tokenizers_and_torch_constraint.py (4.5k tokens)
      ├── test_unsloth_run_tool_policy_resolver.py (1100 tokens)
   ├── qlora/
      ├── README.md (500 tokens)
      ├── test_hf_qlora_train_and_merge.py (1000 tokens)
      ├── test_unsloth_qlora_train_and_merge.py (1200 tokens)
   ├── run_all.sh (100 tokens)
   ├── saving/
      ├── gpt-oss-merge/
         ├── run_test.sh (100 tokens)
         ├── test_merged_model.py (300 tokens)
         ├── train_and_merge.py (500 tokens)
      ├── language_models/
         ├── test_merge_4bit_validation.py (1300 tokens)
         ├── test_merge_model_perplexity_llama-3.2.py (1600 tokens)
         ├── test_merge_model_perplexity_mistral.py (1900 tokens)
         ├── test_merge_model_perplexity_phi_4.py (1500 tokens)
         ├── test_merged_model_perplexity_llama-3.1-8b.py (1600 tokens)
         ├── test_merged_model_perplexity_qwen_2.5.py (1800 tokens)
         ├── test_push_to_hub_merged.py (1100 tokens)
         ├── test_push_to_hub_merged_sharded_index_file.py (1300 tokens)
         ├── test_save_merged_grpo_model.py (5.3k tokens)
      ├── non_peft/
         ├── test_mistral_non_peft.py (400 tokens)
         ├── test_whisper_non_peft.py (400 tokens)
      ├── test_fix_sentencepiece_gguf_robustness.py (900 tokens)
      ├── test_patch_saving_none_tokenizer.py (200 tokens)
      ├── test_save_shell_injection.py (500 tokens)
      ├── test_unsloth_save.py (2.4k tokens)
      ├── text_to_speech_models/
         ├── test_csm.py (1000 tokens)
         ├── test_lasa.py (1400 tokens)
         ├── test_orpheus.py (1700 tokens)
         ├── test_whisper.py (1300 tokens)
      ├── vision_models/
         ├── test_index_file_sharded_model.py (2000 tokens)
         ├── test_push_to_hub_merged.py (1800 tokens)
         ├── test_save_merge_qwen2.5vl32B_model_ocr_benchmark.py (1900 tokens)
         ├── test_save_merge_vision_model_ocr_benchmark.py (1900 tokens)
   ├── security/
      ├── __init__.py
      ├── conftest.py (600 tokens)
      ├── fixtures/
         ├── __init__.py
         ├── _build.py (1300 tokens)
         ├── clean_lockfile.json (100 tokens)
         ├── clean_wheel.whl
         ├── malicious_lockfile.json (200 tokens)
         ├── malicious_sdist.tar.gz
         ├── malicious_wheel.whl
         ├── structural_only_lockfile.json (100 tokens)
      ├── test_lint_workflow_triggers.py (900 tokens)
      ├── test_lockfile_supply_chain_audit.py (1900 tokens)
      ├── test_new_install_scripts.py (1400 tokens)
      ├── test_scan_npm_packages.py (1900 tokens)
      ├── test_scan_packages.py (1900 tokens)
   ├── sh/
      ├── test_get_torch_index_url.sh (2000 tokens)
      ├── test_install_host_defaults.sh (600 tokens)
      ├── test_mac_intel_compat.sh (3.7k tokens)
      ├── test_tauri_install_exit_order.sh (700 tokens)
      ├── test_torch_constraint.sh (2.2k tokens)
   ├── studio/
      ├── _playwright_robust.py (4.1k tokens)
      ├── install/
         ├── smoke_test_llama_prebuilt.py (1000 tokens)
         ├── smoke_test_parallel_studio_home.py (3.2k tokens)
         ├── test_install_llama_prebuilt_logic.py (17.5k tokens)
         ├── test_llama_pr_force_and_source.py (5k tokens)
         ├── test_pr4562_bugfixes.py (9.7k tokens)
         ├── test_rocm_support.py (11.9k tokens)
         ├── test_selection_logic.py (16.2k tokens)
      ├── playwright_chat_ui.py (12.5k tokens)
      ├── playwright_extra_ui.py (5k tokens)
      ├── run_real_mlx_smoke.py (4k tokens)
      ├── studio_api_smoke.py (5.1k tokens)
      ├── test_cancel_atomicity.py (1900 tokens)
      ├── test_cancel_id_wiring.py (1300 tokens)
      ├── test_chat_preset_builtin_invariants.py (1900 tokens)
      ├── test_cli_repo_variant.py (900 tokens)
      ├── test_cli_run_alias.py (500 tokens)
      ├── test_cli_studio_defaults.py (700 tokens)
      ├── test_export_output_path_contract.py (900 tokens)
      ├── test_hardware_dispatch_matrix.py (3.1k tokens)
      ├── test_is_mlx_dispatch_gate.py (1800 tokens)
      ├── test_llama_cpp_wall_clock_cap.py (900 tokens)
      ├── test_mlx_training_worker_behaviors.py (700 tokens)
      ├── test_stream_cancel_registration_timing.py (4.8k tokens)
      ├── test_studio_gguf_export_script_pin.py (1600 tokens)
      ├── test_studio_text_descender_clipping.py (500 tokens)
   ├── test_cli_export_unpacking.py (1100 tokens)
   ├── test_gemma4_chat_template.py (1200 tokens)
   ├── test_get_model_name.py (1300 tokens)
   ├── test_import_fixes_drift.py (4.5k tokens)
   ├── test_loader_glob_skip.py (1100 tokens)
   ├── test_model_registry.py (600 tokens)
   ├── test_multi_image_grpo_chunking.py (1300 tokens)
   ├── test_peft_weight_converter_compat.py (1600 tokens)
   ├── test_public_api_surface.py (1700 tokens)
   ├── test_raw_text.py (1900 tokens)
   ├── test_resolve_model_class.py (700 tokens)
   ├── test_studio_install_workspace_guard.py (9.2k tokens)
   ├── test_studio_root_resilience.py (1300 tokens)
   ├── utils/
      ├── __init__.py (200 tokens)
      ├── aime_eval.md (1300 tokens)
      ├── aime_eval.py (3.9k tokens)
      ├── cleanup_utils.py (1400 tokens)
      ├── data_utils.py (1100 tokens)
      ├── hf_utils.py (1700 tokens)
      ├── ocr_eval.md (600 tokens)
      ├── ocr_eval.py (2.5k tokens)
      ├── os_utils.py (800 tokens)
      ├── perplexity_eval.md (100 tokens)
      ├── perplexity_eval.py (600 tokens)
      ├── test_attention_masks.py (1600 tokens)
      ├── test_packing.py (2.6k tokens)
      ├── test_q_galore.py (4.1k tokens)
      ├── test_qat.py (1400 tokens)
      ├── test_trunc_normal_patch.py (900 tokens)
   ├── version_compat/
      ├── __init__.py
      ├── _fetch.py (600 tokens)
      ├── test_bitsandbytes_pinned_symbols.py (2.4k tokens)
      ├── test_peft_pinned_symbols.py (3.3k tokens)
      ├── test_sentence_transformers_pinned_symbols.py (1900 tokens)
      ├── test_transformers_pinned_symbols.py (3.7k tokens)
      ├── test_trl_grpo_pinned_symbols.py (6k tokens)
   ├── vllm_compat/
      ├── __init__.py
      ├── test_extended_module_imports.py (2.6k tokens)
      ├── test_unsloth_zoo_imports.py (1600 tokens)
      ├── test_vllm_pinned_symbols.py (2.5k tokens)
├── unsloth-cli.py (3.1k tokens)
├── unsloth/
   ├── __init__.py (1100 tokens)
   ├── _auto_install.py (500 tokens)
   ├── _gpu_init.py (2.8k tokens)
   ├── chat_templates.py (25.1k tokens)
   ├── dataprep/
      ├── __init__.py (100 tokens)
      ├── raw_text.py (2.7k tokens)
      ├── synthetic.py (3.2k tokens)
      ├── synthetic_configs.py (800 tokens)
   ├── device_type.py (1100 tokens)
   ├── import_fixes.py (15.9k tokens)
   ├── kernels/
      ├── __init__.py (500 tokens)
      ├── cross_entropy_loss.py (2.9k tokens)
      ├── fast_lora.py (4.1k tokens)
      ├── flex_attention.py (1400 tokens)
      ├── fp8.py (4.7k tokens)
      ├── geglu.py (1800 tokens)
      ├── layernorm.py (1400 tokens)
      ├── moe/
         ├── LICENSE (6.9k tokens)
         ├── README.md (1200 tokens)
         ├── __init__.py
         ├── autotune_cache.py (3.4k tokens)
         ├── benchmark/
            ├── benchmark_fused_moe.py (2.8k tokens)
            ├── utils.py (1400 tokens)
         ├── grouped_gemm/
            ├── LICENSE (6.9k tokens)
            ├── __init__.py
            ├── interface.py (8.2k tokens)
            ├── kernels/
               ├── __init__.py
               ├── autotuning.py (2.4k tokens)
               ├── backward.py (4.7k tokens)
               ├── forward.py (2.2k tokens)
               ├── tuning.py (1700 tokens)
            ├── reference/
               ├── __init__.py
               ├── layers/
                  ├── llama4_moe.py (3.5k tokens)
                  ├── qwen3_moe.py (2.7k tokens)
               ├── moe_block.py (1200 tokens)
               ├── moe_ops.py (900 tokens)
         ├── requirements.txt
         ├── tests/
            ├── __init__.py
            ├── common.py (2k tokens)
            ├── moe_utils.py (3.7k tokens)
            ├── run_qwen3_moe_tests.sh (200 tokens)
            ├── test_grouped_gemm.py (8.9k tokens)
            ├── test_llama4_moe.py (1700 tokens)
            ├── test_qwen3_moe.py (2k tokens)
      ├── rms_layernorm.py (2k tokens)
      ├── rope_embedding.py (2.8k tokens)
      ├── swiglu.py (900 tokens)
      ├── utils.py (7k tokens)
   ├── models/
      ├── __init__.py (300 tokens)
      ├── _utils.py (25.6k tokens)
      ├── cohere.py (3.9k tokens)
      ├── dpo.py (200 tokens)
      ├── falcon_h1.py (5.8k tokens)
      ├── gemma.py (3.9k tokens)
      ├── gemma2.py (5k tokens)
      ├── glm4_moe.py (3k tokens)
      ├── granite.py (4.7k tokens)
      ├── llama.py (29.4k tokens)
      ├── llama4.py (100 tokens)
      ├── loader.py (14.5k tokens)
      ├── loader_utils.py (3.2k tokens)
      ├── mapper.py (10.1k tokens)
      ├── mistral.py (3.7k tokens)
      ├── qwen2.py (700 tokens)
      ├── qwen3.py (3.5k tokens)
      ├── qwen3_moe.py (1900 tokens)
      ├── rl.py (19.4k tokens)
      ├── rl_replacements.py (18.5k tokens)
      ├── sentence_transformer.py (18.2k tokens)
      ├── vision.py (14.5k tokens)
   ├── ollama_template_mappers.py (16.7k tokens)
   ├── optimizers/
      ├── __init__.py (200 tokens)
      ├── q_galore_adamw.py (3.2k tokens)
      ├── q_galore_projector.py (2.8k tokens)
   ├── registry/
      ├── REGISTRY.md (700 tokens)
      ├── __init__.py (500 tokens)
      ├── _deepseek.py (1400 tokens)
      ├── _gemma.py (500 tokens)
      ├── _llama.py (800 tokens)
      ├── _mistral.py (600 tokens)
      ├── _phi.py (500 tokens)
      ├── _qwen.py (900 tokens)
      ├── registry.py (1200 tokens)
   ├── save.py (26.7k tokens)
   ├── tokenizer_utils.py (13.9k tokens)
   ├── trainer.py (4.6k tokens)
   ├── utils/
      ├── __init__.py (300 tokens)
      ├── attention_dispatch.py (2.6k tokens)
      ├── hf_hub.py (400 tokens)
      ├── packing.py (2.7k tokens)
├── unsloth_cli/
   ├── __init__.py (200 tokens)
   ├── _tool_policy.py (500 tokens)
   ├── commands/
      ├── __init__.py
      ├── export.py (900 tokens)
      ├── inference.py (500 tokens)
      ├── studio.py (8.4k tokens)
      ├── train.py (1000 tokens)
   ├── config.py (1100 tokens)
   ├── options.py (1100 tokens)
```


## /.github/CODEOWNERS

```github/CODEOWNERS path="/.github/CODEOWNERS" 
# Inspired from https://github.com/vllm-project/vllm/blob/main/.github/CODEOWNERS

/unsloth/models/loader.py @danielhanchen @mmathew23
/unsloth/models/llama.py @Datta0 @danielhanchen @mmathew23
/unsloth/models/rl.py @Datta0 @pluesclues @danielhanchen
/unsloth/models/rl_replacements.py @Datta0 @pluesclues @danielhanchen
/unsloth/trainer.py @danielhanchen
/unsloth/models/sentence_transformer.py @Etherll @danielhanchen
/unsloth/save.py @rolandtannous @danielhanchen
/unsloth/tokenizer_utils.py @mmathew23 @danielhanchen
/unsloth/chat_templates.py @rolandtannous @danielhanchen
/unsloth/ollama_template_mappers.py @rolandtannous @danielhanchen
/unsloth/kernels/moe/*.py @Datta0
/unsloth/import_fixes.py @danielhanchen
/unsloth/device_type.py @danielhanchen
/unsloth/_auto_install.py @danielhanchen
/unsloth/dataprep/*.py @danielhanchen
/unsloth/kernels/cross_entropy_loss.py @danielhanchen
/unsloth/kernels/fast_lora.py @danielhanchen
/unsloth/kernels/flex_attention.py @danielhanchen
/unsloth/kernels/fp8.py @Datta0
/unsloth/kernels/geglu.py @danielhanchen
/unsloth/kernels/layernorm.py @danielhanchen
/unsloth/kernels/rms_layernorm.py @danielhanchen
/unsloth/kernels/rope_embedding.py @danielhanchen
/unsloth/kernels/swiglu.py @danielhanchen
/unsloth/kernels/utils.py @danielhanchen @Datta0
/unsloth/models/_utils.py @danielhanchen @mmathew23
/unsloth/models/cohere.py @danielhanchen
/unsloth/models/dpo.py @danielhanchen
/unsloth/models/falcon_h1.py @danielhanchen
/unsloth/models/gemma.py @danielhanchen
/unsloth/models/gemma2.py @danielhanchen
/unsloth/models/glm4_moe.py @Datta0
/unsloth/models/granite.py @danielhanchen
/unsloth/models/llama4.py @danielhanchen
/unsloth/models/loader_utils.py @Datta0 @danielhanchen
/unsloth/models/mapper.py @danielhanchen
/unsloth/models/mistral.py @danielhanchen
/unsloth/models/qwen2.py @danielhanchen
/unsloth/models/qwen3.py @Datta0
/unsloth/models/qwen3_moe.py @Datta0
/unsloth/models/vision.py @mmathew23 @danielhanchen
/unsloth/utils/attention_dispatch.py @mmathew23
/unsloth/utils/hf_hub.py @mmathew23
/unsloth/utils/packing.py @mmathew23

/cli/ @rolandtannous @Manan17
/studio/frontend/ @Shine1i @rolandtannous @Manan17
/studio/frontend/public/ @Shine1i
/studio/backend/ @rolandtannous
/studio/backend/core/data_recipe/ @rolandtannous
/studio/backend/tests/ @rolandtannous @danielhanchen
/tests/ @rolandtannous @danielhanchen
/scripts/ @rolandtannous @danielhanchen

# Snapshot data for the notebook linter / Colab oracle. Drift in these
# files changes the pin floor for every Unsloth notebook, so refreshes
# must be reviewed by the notebook owners directly. CODEOWNERS later
# wins, so this overrides the broader /scripts/ rule above.
/scripts/data/colab_*.txt  @danielhanchen @shimmyshimmer
/scripts/data/colab_*.json @danielhanchen @shimmyshimmer

```

## /.github/FUNDING.yml

```yml path="/.github/FUNDING.yml" 
# These are supported funding model platforms

github: unslothai
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # unsloth
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

```

## /.github/ISSUE_TEMPLATE/bug---issue.md

---
name: Bug / Issue
about: Bug / Issue
title: "[Bug] Please fill in your issue title here."
labels: bug
assignees: ''

---
Note: Please do not remove the questions. Answer beside them.
1. Did you update? `pip install --upgrade unsloth unsloth_zoo`
2. `Colab` or `Kaggle` or local / cloud
3. Number GPUs used, use `nvidia-smi`
4. Which notebook? Please link!
5. Which Unsloth version, TRL version, transformers version, PyTorch version?
6. Which trainer? `SFTTrainer`, `GRPOTrainer` etc

```python
Put Minimal code to reproduce error here ###Remove Hugging Face token###
###Please make sure to check formatting properly, edit if needed.###
```

🦥 You can also ask via our Reddit page: https://reddit.com/r/unsloth/


## /.github/ISSUE_TEMPLATE/feature-request.md

---
name: Feature Request
about: New features, model support, ideas
title: "[Feature]"
labels: feature request
assignees: ''

---

For new models, have you tried:
```python
from unsloth import FastModel
model, tokenizer = FastModel.from_pretrained(
    "microsoft/Phi-4-multimodal-instruct",
    trust_remote_code = True,
)
from transformers import AutoModelForSequenceClassification
model, tokenizer = FastModel.from_pretrained(
    auto_model = AutoModelForSequenceClassification,
)
```


## /.github/dependabot.yml

```yml path="/.github/dependabot.yml" 
---
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      # github-actions refs are git tags / SHAs, not semver -- the
      # `semver-minor-days` / `semver-patch-days` knobs are rejected
      # by Dependabot's validator for this ecosystem. Only the
      # `default-days` floor applies.
      default-days: 7
    groups:
      actions:
        patterns: ["*"]
      actions-security:
        applies-to: security-updates
        patterns: ["*"]

  # Removed a stray `package-ecosystem: "bun"` entry for
  # /studio/frontend: that path has no bun.lock / bun.lockb, so
  # Dependabot's bun ecosystem silently no-ops on it. The actual
  # lockfile committed at /studio/frontend is package-lock.json
  # (npm), and the npm entry further below already catches
  # npm_and_yarn security advisories for that directory. Version
  # updates for /studio/frontend stay suppressed (open-pull-
  # requests-limit: 0 in that entry) -- security PRs flow through
  # regardless. Add a real bun entry IF and WHEN bun.lock lands.

  - package-ecosystem: "npm"
    directory: "/studio/backend/core/data_recipe/oxc-validator"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7
      semver-minor-days: 3
      semver-patch-days: 3
    groups:
      npm-oxc-validator:
        patterns: ["*"]
      npm-oxc-validator-security:
        applies-to: security-updates
        patterns: ["*"]

  # pip + cargo grouped weekly; the *-security siblings batch
  # advisories that would otherwise each open their own PR.
  - package-ecosystem: "pip"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5
    cooldown:
      default-days: 7
    groups:
      python:
        patterns: ["*"]
      python-security:
        applies-to: security-updates
        patterns: ["*"]

  - package-ecosystem: "cargo"
    directory: "/studio/src-tauri"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7
      semver-minor-days: 3
      semver-patch-days: 3
    groups:
      cargo-tauri:
        patterns: ["*"]
      cargo-tauri-security:
        applies-to: security-updates
        patterns: ["*"]

  # /studio/frontend npm dependencies. Version-update PRs are
  # deliberately suppressed (open-pull-requests-limit: 0) -- the
  # frontend dep tree is large, the lockfile is the authoritative
  # pin, and `min-release-age=7` in studio/frontend/.npmrc already
  # blocks fresh tarballs at install time. Security advisories
  # arrive via GitHub's npm_and_yarn channel and are NOT capped by
  # `open-pull-requests-limit` per Dependabot's documented
  # behaviour; they flow through this entry, group together, and
  # still respect the cooldown below so we never ingest a tarball
  # that was hot-published less than 3 days ago.
  - package-ecosystem: "npm"
    directory: "/studio/frontend"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 0
    cooldown:
      default-days: 7
      semver-minor-days: 3
      semver-patch-days: 3
    groups:
      npm-frontend-security:
        applies-to: security-updates
        patterns: ["*"]
...

```

## /.github/workflows/consolidated-tests-ci.yml

```yml path="/.github/workflows/consolidated-tests-ci.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# One consolidated CPU-only job that runs every test_* function the existing
# CI does not already cover from this repo plus the full unsloth_zoo@main
# CPU test suite plus unsloth_zoo.compiler.test_apply_fused_lm_head.
#
# Why a separate workflow:
#   - studio-backend-ci.yml's "Repo tests (CPU)" job already auto-discovers
#     tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 16
#     Bucket-A tests below live inside those --ignore dirs (CPU-runnable but
#     historically excluded with their GPU siblings); pulling them out into
#     a sibling job keeps the existing 760-passed baseline stable while we
#     prove the new pieces are green.
#   - unsloth_zoo has no CI on main today (.github/workflows/ is empty
#     upstream as of HEAD 030e4ba). 106 of its 111 test_* functions are
#     CPU-runnable; the 5 GPU/vLLM ones are deselected here.
#   - test_apply_fused_lm_head lives at unsloth_zoo/compiler.py:1983, not
#     under tests/, so it is not picked up by `pytest tests/`. It is a
#     plain function with no fixtures: pure regex over transformers source
#     strings, ~5-15 s wall, no GPU.
#
# Strict mode: every test step is gating (no `continue-on-error`). The
# upstream patch fixes that previously caused per-cell red have landed:
#   - unslothai/unsloth#5319 (patch_fast_lora import, patch_sft_trainer
#     Union, openenv OSError graceful skip).
#   - unslothai/unsloth-zoo#628 (MoE coverage canary so old transformers
#     skips legitimately while real discovery regressions still fail).
# After those merges every observed cell failure was one of these two
# things; if they regress we want a red cell, not a green-with-fail-prints
# cell.

name: Core

on:
  pull_request:
    paths:
      - 'unsloth/**'
      - 'unsloth_cli/**'
      - 'studio/**'
      - 'tests/**'
      - 'pyproject.toml'
      - '.github/workflows/consolidated-tests-ci.yml'
  push:
    branches: [main, pip]
  workflow_dispatch:
    inputs:
      unsloth_zoo_ref:
        description: 'unsloth_zoo git ref to test against (default main)'
        required: false
        default: 'main'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  consolidated:
    # Matrix: three (transformers, TRL) combos cover the failure surface the
    # PR cares about:
    #   1. transformers==4.57.6 + TRL latest <1.0.0 (the just-before-5.x line)
    #   2. transformers latest 5.x + TRL latest 1.x (the absolute upstream tip;
    #      currently 5.8.0 + 1.3.0, both BEYOND the unsloth/unsloth_zoo
    #      <=5.5.0 / <=0.24.0 caps -- the cell exists explicitly to surface
    #      drift signal)
    #   3. transformers + TRL pinned by pyproject.toml's dependency entries
    #      (resolved dynamically at job time via tomllib)
    # fail-fast: false so each cell runs independently and a transformers /
    # TRL drift signal in one cell does not cancel the others. No
    # job-level or per-step `continue-on-error` -- real test failures now
    # fail the cell. Patches with legitimate CPU-runner preconditions
    # (real CUDA dispatcher, runtime args) are explicitly skipped via
    # NEEDS_PRECONDITION in the runtime check shim below.
    strategy:
      fail-fast: false
      matrix:
        combo:
          - id: t4576-trl0latest
            label: "HF=4.57.6 + TRL<1"
            transformers_spec: "transformers==4.57.6"
            trl_spec: "trl>=0.18.2,<1.0.0"
          - id: tlatest5-trl1latest
            label: "HF=latest + TRL=latest"
            transformers_spec: "transformers>=5,<6"
            trl_spec: "trl>=1,<2"
          - id: pyproject
            label: "HF=default + TRL=default"
            transformers_spec: "__from_pyproject__"
            trl_spec: "__from_pyproject__"
    name: "Core (${{ matrix.combo.label }})"
    runs-on: ubuntu-latest
    timeout-minutes: 35
    # No job-level or per-step `continue-on-error`. Earlier iterations
    # masked real test failures behind green check icons; that lie is
    # gone. A failing test step fails the cell. NEEDS_PRECONDITION in
    # the runtime check shim handles patches that legitimately cannot
    # run on a CPU-only runner (real CUDA dispatcher, runtime args).
    env:
      UNSLOTH_ZOO_REF: ${{ inputs.unsloth_zoo_ref || 'main' }}
      MATRIX_TRANSFORMERS_SPEC: ${{ matrix.combo.transformers_spec }}
      MATRIX_TRL_SPEC: ${{ matrix.combo.trl_spec }}
      MATRIX_COMBO_ID: ${{ matrix.combo.id }}
      # Hoisted to job-level so every step (Sanity, Bucket-A, unsloth_zoo
      # pytest, test_apply_fused_lm_head) inherits it. transformers' bundled
      # *_pb2.py was generated against an older protoc; the C++ protobuf
      # 4+/5+/6 implementation rejects them with "Descriptors cannot be
      # created directly". The pure-Python parser bypasses the check; the
      # speed cost is negligible for these tests.
      PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
      PYTHONPATH: ${{ github.workspace }}/studio
      UNSLOTH_COMPILE_DISABLE: '1'
      # unsloth_zoo/__init__.py:314 raises ImportError unless UNSLOTH_IS_PRESENT
      # is set — normally it is set by unsloth.__init__ when unsloth is imported
      # first. In this job we sometimes import unsloth_zoo.* (e.g.
      # unsloth_zoo.saving_utils, unsloth_zoo.temporary_patches) without going
      # through `import unsloth` first; pin the env var to 1 so unsloth_zoo's
      # bootstrap accepts it. Setting it has no effect on unsloth itself.
      UNSLOTH_IS_PRESENT: '1'
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      # Node 22 unblocks tests/studio/test_chat_preset_builtin_invariants.py's
      # `node --experimental-strip-types` subprocess. Cheap to install; keeps
      # the consolidated job self-sufficient even if studio-backend-ci.yml
      # changes its node setup.
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e  # v6.4.0
        with:
          node-version: '22'

      - name: Install uv (some unsloth_zoo dev tooling expects it on PATH)
        run: pip install uv

      - name: Resolve matrix specs (handle __from_pyproject__ sentinel)
        # The pyproject cell uses a sentinel; resolve the real `transformers`
        # and `trl` constraints from the project's pyproject.toml at job time.
        # unsloth's pyproject puts the LLM stack pins in
        # [project.optional-dependencies] under the `huggingfacenotorch`
        # extra (top-level [project.dependencies] is just typer/pydantic/etc.),
        # so we walk every optional extra and pick the first matching spec.
        # Other cells pass their spec through unchanged.
        run: |
          set -euxo pipefail
          python <<'PY' >> "$GITHUB_ENV"
          import os, re, tomllib
          spec_t = os.environ["MATRIX_TRANSFORMERS_SPEC"]
          spec_r = os.environ["MATRIX_TRL_SPEC"]

          def _pkg_name(spec: str) -> str:
              m = re.match(r"\s*([A-Za-z0-9_.-]+)", spec)
              return (m.group(1).lower() if m else "")

          if spec_t == "__from_pyproject__" or spec_r == "__from_pyproject__":
              with open("pyproject.toml", "rb") as f:
                  doc = tomllib.load(f)
              proj = doc.get("project", {})
              # Try top-level deps first, then all optional extras.
              all_deps: list[str] = list(proj.get("dependencies", []))
              for _name, dep_list in proj.get("optional-dependencies", {}).items():
                  all_deps.extend(dep_list)

              if spec_t == "__from_pyproject__":
                  spec_t = next((x for x in all_deps if _pkg_name(x) == "transformers"),
                                "transformers")
              if spec_r == "__from_pyproject__":
                  spec_r = next((x for x in all_deps if _pkg_name(x) == "trl"),
                                "trl")
          print(f"RESOLVED_TRANSFORMERS_SPEC={spec_t}")
          print(f"RESOLVED_TRL_SPEC={spec_r}")
          PY
          # Echo to logs so the matrix cell label maps cleanly to a spec.
          grep RESOLVED_ "$GITHUB_ENV" || true

      - name: Install runtime deps (mirrors studio-backend-ci.yml + mlx-ci.yml)
        # The shape matches studio-backend-ci.yml's "Repo tests (CPU)" install
        # so we inherit the same CPU-spoof harness in tests/conftest.py and
        # the same import-chain guarantees, plus the extra deps that the
        # tests/saving + tests/utils Bucket-A files transitively need but
        # which Repo tests (CPU) does not require because it --ignores
        # those directories:
        #   - protobuf + sentencepiece: tests/saving/test_fix_sentencepiece_gguf_robustness.py
        #     does `from transformers.utils import sentencepiece_model_pb2`,
        #     which imports `google.protobuf`. Not pulled by transformers'
        #     base install.
        #   - triton: unsloth/_gpu_init.py:232 does an unconditional
        #     `import triton`. The triton PyPI wheel installs cleanly on
        #     Linux x86_64 even without CUDA (the import succeeds; runtime
        #     GPU work is what would fail, which we never do here).
        # transformers + trl are matrix-parameterized.
        run: |
          set -euxo pipefail
          python -m pip install --upgrade pip
          pip install -r studio/backend/requirements/studio.txt
          pip install \
            python-multipart aiofiles sqlalchemy cryptography \
            pyyaml jinja2 mammoth unpdf requests typer \
            'numpy<3' pytest==9.0.3 pytest-asyncio httpx \
            protobuf sentencepiece triton \
            psutil packaging tqdm safetensors datasets \
            'peft>=0.18,<0.20' 'accelerate>=0.34,<2' \
            ipython
          # torchvision: unsloth_zoo.vision_utils imports it at module scope.
          pip install --index-url https://download.pytorch.org/whl/cpu \
            'torch>=2.4,<2.11' 'torchvision<0.26'
          # transformers + trl from the matrix combo.
          pip install "$RESOLVED_TRANSFORMERS_SPEC"
          pip install "$RESOLVED_TRL_SPEC"
          # bitsandbytes: hard import in unsloth/models/_utils.py. Recent
          # versions ship a CPU build that imports cleanly on Linux.
          pip install 'bitsandbytes>=0.45'
          # unsloth itself, editable, no-deps so pip does not fight the
          # explicit torch CPU-index install above.
          pip install -e . --no-deps
          echo "::group::Installed transformers + trl + torch + unsloth versions"
          pip show transformers
          pip show trl
          pip show torch
          pip show unsloth
          echo "::endgroup::"

      - name: Clone unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }}
        # We need the repository tree (the wheel does not ship tests/), so
        # clone shallow then editable-install so unsloth_zoo.* imports
        # resolve to the cloned tree. We use `pip show` for the location
        # check rather than `import unsloth_zoo` because the latter calls
        # device_type.get_device_type() at module load and raises on a
        # GPU-less runner; pytest steps below route through the existing
        # tests/conftest.py spoof which handles that.
        run: |
          set -euxo pipefail
          # github.com occasionally 500s on the git fetch; retry so a
          # single upstream blip does not fail CI.
          for attempt in 1 2 3; do
            rm -rf "$RUNNER_TEMP/unsloth-zoo"
            if git clone --depth=1 --branch="$UNSLOTH_ZOO_REF" \
                https://github.com/unslothai/unsloth-zoo \
                "$RUNNER_TEMP/unsloth-zoo"; then
              break
            fi
            if [ "$attempt" -eq 3 ]; then
              echo "::error::git clone unsloth-zoo failed after 3 attempts"
              exit 1
            fi
            delay=$((5 * attempt))
            echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
            sleep "$delay"
          done
          pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
          pip show unsloth_zoo

      - name: Sanity — collection only (both repos)
        # Catches import-time breakage before we run the suite. Cheap; bails
        # the job out fast if a transformers/torch resolution went sideways.
        # Inherits PYTHONPATH / UNSLOTH_COMPILE_DISABLE / PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION
        # from the job-level env block.
        run: |
          set -euxo pipefail
          python -m pytest --collect-only -q \
            tests/saving/test_save_shell_injection.py \
            tests/saving/test_patch_saving_none_tokenizer.py \
            tests/saving/test_fix_sentencepiece_gguf_robustness.py \
            tests/utils/test_attention_masks.py \
            tests/utils/test_trunc_normal_patch.py
          python -m pytest --collect-only -q "$RUNNER_TEMP/unsloth-zoo/tests/"

      - name: import_fixes drift detectors (18 tests, HARD GATE)
        # One drift detector per fix_* / patch_* function in
        # unsloth/import_fixes.py. The detectors assert the *healthy*
        # upstream shape that the fix expects ABSENT the regression;
        # ANY DRIFT DETECTED -> pytest.fail (NEVER skip) so the
        # matrix cell goes red and the maintainer triages on the
        # next PR, not in a downstream user's crash report.
        #
        # Pathologies covered by the suite (each maps to one fix
        # function with the line range cited in the test docstring):
        #   * protobuf MessageFactory GetPrototype / GetMessageClass
        #   * datasets 4.4.x recursion range
        #   * TRL tuple-vs-bool _*_available caching
        #   * transformers PreTrainedModel.enable_input_require_grads
        #     source pattern flip
        #   * transformers torchcodec / causal_conv1d availability
        #     flags
        #   * transformers + accelerate is_wandb_available
        #   * peft.utils.transformers_weight_conversion importability
        #     + build_peft_weight_mapping signature
        #   * triton 3.6+ CompiledKernel num_ctas / cluster_dims
        #   * torch / torchvision pinned compatibility table
        #   * vllm guided_decoding_params / structured_outputs +
        #     aimv2 ovis config version
        #   * huggingface_hub is_offline_mode / HF_HUB_OFFLINE
        #   * torch.nn.init.trunc_normal_ presence (patch site for
        #     patch_trunc_normal_precision_issue)
        #   * xformers post-num_splits-key fix version
        # HARD GATE: a red cell here is a real upstream regression
        # without a corresponding zoo / unsloth-side workaround.
        run: |
          python -m pytest -v --tb=short tests/test_import_fixes_drift.py

      - name: public-api surface drift detectors (9 tests, HARD GATE)
        # Companion to test_import_fixes_drift.py: that file catches
        # third-party drift; this one catches drift in unsloth's OWN
        # public surface (FastLanguageModel / FastVisionModel /
        # FastModel + their classmethods + is_bf16_supported). A
        # rename here would silently break the unslothai/notebooks tree
        # one PR cycle later -- this gate catches it BEFORE the
        # breakage reaches users.
        run: |
          python -m pytest -v --tb=short tests/test_public_api_surface.py

      - name: unsloth Bucket-A — CPU tests not in Repo tests (CPU)
        # 16 tests across 5 files. They live inside tests/saving/ and
        # tests/utils/, both of which Repo tests (CPU) excludes via --ignore
        # because their sibling files need real GPUs / real HF weights.
        # The five files below are pure-Python + AST/protobuf/regex tests
        # that run cleanly on CPU. Env inherited from the job block.
        run: |
          python -m pytest -q --tb=short \
            tests/saving/test_save_shell_injection.py \
            tests/saving/test_patch_saving_none_tokenizer.py \
            tests/saving/test_fix_sentencepiece_gguf_robustness.py \
            tests/utils/test_attention_masks.py \
            tests/utils/test_trunc_normal_patch.py \
            --deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
          # The deselected test monkeypatches flash_attn_varlen_func, which is
          # only bound on the module when `flash_attn` is importable. flash_attn
          # requires CUDA + dev toolchain, which the CPU-only ubuntu-latest
          # runner does not have. The other 15 Bucket-A tests pass cleanly.

      - name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU)
        # 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip
        # cases below auto-skip on a GPU-less runner; deselect them
        # explicitly so the no-CUDA outcome is "deselected", not "skipped",
        # making intent visible in the report. Env inherited from job block.
        working-directory: ${{ runner.temp }}/unsloth-zoo
        run: |
          python -m pytest -q --tb=short tests/ \
            --deselect tests/test_unsloth_zoo_lora_merge.py::test_active_merge_device_returns_string_on_cuda_host \
            --deselect tests/test_unsloth_zoo_lora_merge.py::test_merge_lora_moves_cpu_inputs_to_active_device

      - name: unsloth_zoo — test_apply_fused_lm_head (lives in compiler.py)
        # `test_apply_fused_lm_head` lives at unsloth_zoo/compiler.py:1983,
        # not under tests/, so pytest's default discovery does not pick it up.
        # We route it through pytest by writing a one-shot shim test file
        # inside the unsloth checkout's tests/ — pytest then walks UP and
        # picks up tests/conftest.py, whose GPU-spoof harness (lines 84-141)
        # patches torch.cuda.is_available, torch.cuda.memory.mem_get_info,
        # torch.cuda.get_device_capability, and is_bf16_supported. That full
        # spoof is required because unsloth_zoo/temporary_patches/gpt_oss.py
        # at module load reads torch.cuda.memory.mem_get_info(0), which
        # bare `is_available = True` doesn't cover. Env inherited.
        run: |
          set -euxo pipefail
          cat > tests/_zoo_apply_fused_lm_head_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          # Wraps unsloth_zoo.compiler.test_apply_fused_lm_head so that
          # tests/conftest.py's GPU-spoof harness applies before the import.
          # _zoo_aggressive_cuda_spoof extends conftest's harness with deeper
          # patches (see tests/_zoo_aggressive_cuda_spoof.py).
          import sys, pathlib
          sys.path.insert(0, str(pathlib.Path(__file__).parent))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()
          from unsloth_zoo.compiler import test_apply_fused_lm_head as _zoo_test
          def test_zoo_apply_fused_lm_head_runs():
              _zoo_test()
          PY
          python -m pytest -q --tb=short tests/_zoo_apply_fused_lm_head_shim.py
          rm -f tests/_zoo_apply_fused_lm_head_shim.py

      - name: Static checks — unsloth/trainer.py + unsloth/models/rl.py against latest pip TRL
        # AST-only sanity: confirm both files parse and that every TRL symbol
        # they reference still exists in the installed `trl`. Catches API
        # drift (renamed / removed TRL classes) without running training.
        # Pre-fetches latest pip transformers in case TRL pinned an older one.
        run: |
          set -euxo pipefail
          # Use the matrix-resolved transformers + trl versions already
          # installed by the runtime-deps step (don't upgrade here; that
          # would defeat the matrix's purpose of testing against the
          # specific (transformers, trl) combination the cell selected).
          python <<'PY'
          import ast, importlib, pathlib, sys
          paths = [pathlib.Path("unsloth/trainer.py"),
                   pathlib.Path("unsloth/models/rl.py")]
          for p in paths:
              src = p.read_text()
              tree = ast.parse(src, filename=str(p))
              # Collect every `from trl... import X` and `from trl... import (X, Y)`
              missing = []
              for node in ast.walk(tree):
                  if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("trl"):
                      mod = importlib.import_module(node.module)
                      for alias in node.names:
                          if alias.name == "*":
                              continue
                          if not hasattr(mod, alias.name):
                              missing.append(f"{node.module}.{alias.name}")
              print(f"{p}: TRL symbols referenced and resolved -> {'OK' if not missing else 'MISSING ' + ', '.join(missing)}")
              if missing:
                  sys.exit(1)
          PY

      - name: Static checks — unsloth_zoo/tiled_mlp.py against latest pip transformers
        # AST parse + transformers symbol-resolution. The user flagged tiled
        # MLP patching as the path that breaks first when transformers ships
        # an MLP class rename; this step is the canary against whatever
        # transformers version the matrix cell selected.
        working-directory: ${{ runner.temp }}/unsloth-zoo
        run: |
          set -euxo pipefail
          python <<'PY'
          import ast, importlib, pathlib, sys
          p = pathlib.Path("unsloth_zoo/tiled_mlp.py")
          src = p.read_text()
          tree = ast.parse(src, filename=str(p))
          missing = []
          for node in ast.walk(tree):
              if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("transformers"):
                  try:
                      mod = importlib.import_module(node.module)
                  except Exception as e:
                      missing.append(f"{node.module} (import failed: {type(e).__name__})")
                      continue
                  for alias in node.names:
                      if alias.name == "*":
                          continue
                      if not hasattr(mod, alias.name):
                          missing.append(f"{node.module}.{alias.name}")
          print(f"{p}: transformers symbols referenced -> {'OK' if not missing else 'MISSING ' + ', '.join(missing)}")
          if missing:
              sys.exit(1)
          PY

      - name: Static checks — unsloth_zoo/hf_utils.py syntax + import-graph
        working-directory: ${{ runner.temp }}/unsloth-zoo
        run: |
          set -euxo pipefail
          python <<'PY'
          import ast, pathlib
          p = pathlib.Path("unsloth_zoo/hf_utils.py")
          tree = ast.parse(p.read_text(), filename=str(p))
          # Surface every public function + class so the PR check log shows
          # what's covered, not just OK/FAIL.
          public = []
          for node in tree.body:
              if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and not node.name.startswith("_"):
                  public.append(f"{type(node).__name__.replace('Def','').lower()}:{node.name}")
          print(f"hf_utils.py public surface ({len(public)}): " + ", ".join(public))
          PY

      - name: Runtime checks — invoke every zero-arg patch_* across both repos (via pytest shim)
        # Routed through pytest so tests/conftest.py's GPU-spoof harness
        # applies before any unsloth_zoo.temporary_patches.* import.
        # Locally validated 50/51 zero-arg patches succeed; the lone failure
        # surfaces a real bug (unsloth.models._utils.patch_fast_lora raises
        # NameError: name 'fast_lora_forward' is not defined). The shim
        # reports the full ledger but only fails when one of the two
        # `required` helpers is absent.
        run: |
          set -euxo pipefail
          cat > tests/_runtime_patch_check_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          # Wraps the runtime patch_* validation into a pytest test so the
          # tests/conftest.py GPU-spoof harness applies. continue-on-error
          # at the workflow level catches per-patch failures; this shim only
          # asserts that the two `required` helpers are reachable.
          import sys, pathlib
          sys.path.insert(0, str(pathlib.Path(__file__).parent))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()
          import importlib, inspect

          MODULES = [
              "unsloth.models._utils", "unsloth.models.rl", "unsloth.import_fixes",
              "unsloth.kernels.cross_entropy_loss", "unsloth.kernels.rms_layernorm",
              "unsloth.tokenizer_utils", "unsloth.save",
              "unsloth_zoo.patching_utils", "unsloth_zoo.gradient_checkpointing",
              "unsloth_zoo.loss_utils", "unsloth_zoo.tokenizer_utils",
              "unsloth_zoo.tiled_mlp", "unsloth_zoo.dataset_utils",
              "unsloth_zoo.patch_torch_functions",
              "unsloth_zoo.temporary_patches.gemma",
              "unsloth_zoo.temporary_patches.ministral",
              "unsloth_zoo.temporary_patches.pixtral",
              "unsloth_zoo.temporary_patches.deepseek_v3_moe",
              "unsloth_zoo.temporary_patches.qwen3_5_moe",
              "unsloth_zoo.temporary_patches.mxfp4",
              "unsloth_zoo.temporary_patches.bitsandbytes",
              "unsloth_zoo.temporary_patches.flex_attention_bwd",
          ]
          REQUIRED = {
              "patch_unsloth_smart_gradient_checkpointing",
              "patch_gradient_accumulation_fix",
          }
          # Patches whose signature looks zero-arg (`()` or all-defaulted)
          # but which actually require either runtime args or real CUDA.
          # Calling these in isolation is meaningless, so skip the
          # invocation. Symbol presence (REQUIRED above) is still verified.
          #   patch_linear_scaling / patch_llama_rope_scaling: defaults are
          #     None placeholders; the bodies start with
          #     `assert <param> is not None`.
          #   patch_unsloth_smart_gradient_checkpointing: legitimately
          #     allocates CUDA tensors via aten::empty.memory_format inside
          #     initialize_unsloth_gradient_checkpointing(); the
          #     torch.cuda.* spoof can't intercept that at the dispatcher
          #     level.
          NEEDS_PRECONDITION = {
              "patch_linear_scaling",
              "patch_llama_rope_scaling",
              "patch_unsloth_smart_gradient_checkpointing",
          }

          def test_zero_arg_patch_invocations():
              ok, fail, args, skipped, miss_imports = 0, [], [], [], {}
              seen_required = set()
              for mod_name in MODULES:
                  try:
                      mod = importlib.import_module(mod_name)
                  except Exception as e:
                      miss_imports[mod_name] = f"{type(e).__name__}: {e}"
                      continue
                  for name in sorted(dir(mod)):
                      if not name.startswith("patch_"): continue
                      fn = getattr(mod, name, None)
                      if not callable(fn): continue
                      if name in REQUIRED: seen_required.add(name)
                      try:
                          sig = inspect.signature(fn)
                          need = [p.name for p in sig.parameters.values()
                                  if p.default is inspect.Parameter.empty
                                  and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD,
                                                 inspect.Parameter.POSITIONAL_ONLY)]
                      except (TypeError, ValueError):
                          need = []
                      if need:
                          args.append((mod_name, name, need)); continue
                      if name in NEEDS_PRECONDITION:
                          skipped.append(f"{mod_name}.{name}")
                          print(f"  SKIP {mod_name}.{name} (needs precondition / CUDA)")
                          continue
                      try:
                          fn()
                          ok += 1
                          print(f"  OK   {mod_name}.{name}")
                      except Exception as e:
                          fail.append((mod_name, name, type(e).__name__, str(e)[:200]))
                          print(f"  FAIL {mod_name}.{name} -> {type(e).__name__}: {str(e)[:200]}")
              print(f"\nzero-arg patch_*: ok={ok} fail={len(fail)} skipped={len(skipped)}")
              print(f"arg-required patch_* (skipped, listed for review): {len(args)}")
              for m, n, r in args:
                  print(f"    needs={r}: {m}.{n}")
              if skipped:
                  print(f"explicitly skipped (needs precondition / CUDA): {skipped}")
              if miss_imports:
                  print("\nmodules failed to import (skipped):")
                  for k, v in miss_imports.items():
                      print(f"    {k}: {v}")
              print(f"required patch_* helpers seen: {sorted(seen_required)}")
              missing = REQUIRED - seen_required
              assert not missing, f"required patch_* helpers MISSING: {sorted(missing)}"
              # Strict: any zero-arg patch that raises is a real
              # regression now that #5319 has landed (the three previously
              # known-broken patches are fixed; legitimate
              # CPU-precondition skips are recorded in NEEDS_PRECONDITION
              # above, not in `fail`). Print all failures and re-raise
              # them as one assertion message.
              if fail:
                  raise AssertionError(
                      f"zero-arg patch_* invocation failures (ok={ok}, "
                      f"fail={len(fail)}, skipped={len(skipped)}):\n  "
                      + "\n  ".join(
                          f"{m}.{n} -> {ec}: {msg}" for m, n, ec, msg in fail
                      )
                  )
          PY
          python -m pytest -q --tb=short tests/_runtime_patch_check_shim.py -s
          rm -f tests/_runtime_patch_check_shim.py

      - name: Runtime checks — patch_tiled_mlp on a synthetic MLP module (via pytest shim)
        # Same shim pattern: pytest picks up tests/conftest.py before importing
        # unsloth_zoo.tiled_mlp, so the GPU-spoof harness covers
        # unsloth_zoo.temporary_patches.gpt_oss's mem_get_info call.
        run: |
          set -euxo pipefail
          cat > tests/_tiled_mlp_check_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          import sys, pathlib
          sys.path.insert(0, str(pathlib.Path(__file__).parent))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()
          import torch
          import torch.nn as nn
          from unsloth_zoo.tiled_mlp import patch_tiled_mlp, patch_mlp

          class _MLP(nn.Module):
              def __init__(self, hidden=64, intermediate=128):
                  super().__init__()
                  self.gate_proj = nn.Linear(hidden, intermediate, bias=False)
                  self.up_proj   = nn.Linear(hidden, intermediate, bias=False)
                  self.down_proj = nn.Linear(intermediate, hidden, bias=False)
                  self.act_fn = nn.SiLU()
              def forward(self, x):
                  return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))

          class _FakeModel(nn.Module):
              def __init__(self):
                  super().__init__()
                  self.layers = nn.ModuleList([nn.ModuleDict({"mlp": _MLP()}) for _ in range(2)])
              def forward(self, x):
                  for layer in self.layers:
                      x = x + layer["mlp"](x)
                  return x

          def test_patch_tiled_mlp_numerical_equivalence():
              # `patch_mlp(target_arctic=True)` sets `chunk_size = max(1, H)`
              # and shards the SEQUENCE dim with `n_shards = max(1, S //
              # chunk_size)`. Pick S > H so the tiled path actually runs
              # multi-shard (n_shards = 192 // 64 = 3, plus a remainder
              # shard) rather than degenerating to n_shards = 1 which is
              # bit-exact and only confirms patching installed something.
              # If the tiled implementation is correct, multi-shard output
              # must still match the un-tiled reference within FP32 noise.
              torch.manual_seed(0)
              m = _FakeModel().eval()
              hidden = 64
              # 192 = 3 * hidden, so divmod(192, 64) = (3, 0) -> 3 shards,
              # no remainder; gives a clean multi-shard verification.
              x = torch.randn(2, 192, hidden)
              with torch.no_grad():
                  y_before = m(x).clone()
              patch_mlp(m.layers[0]["mlp"])
              patch_tiled_mlp(m)
              # Sanity-check we are actually exercising the multi-shard
              # path: poke chunk_size by re-deriving it the same way
              # `tiled_forward_arctic_size` does.
              S = x.shape[1]
              chunk = max(1, hidden)
              n_shards_expected = max(1, S // chunk)
              assert n_shards_expected > 1, (
                  "tiled MLP shim is not exercising multi-shard: "
                  f"S={S}, chunk={chunk}, n_shards={n_shards_expected}"
              )
              with torch.no_grad():
                  y_after = m(x).clone()
              err = (y_before - y_after).abs().max().item()
              print(
                  f"patch_tiled_mlp multi-shard (n_shards={n_shards_expected}) "
                  f"output diff = {err:.3e}"
              )
              assert err < 1e-3, f"tiled MLP output drifted: {err}"
          PY
          python -m pytest -q --tb=short tests/_tiled_mlp_check_shim.py -s
          rm -f tests/_tiled_mlp_check_shim.py

      - name: Compiler cache hygiene + source-rewriter invariants (synthetic inputs)
        # Lightweight pipeline coverage for unsloth_zoo.compiler. Pure regex
        # / tokenize / ast paths driven by tiny synthetic source strings:
        #   - higher_precision_softmax (basic + idempotent)
        #   - fix_rotary_embedding_dtype (no-op + active under
        #     UNSLOTH_FORCE_CUSTOM_DTYPE)
        #   - fix_attention_dtype_consistency (insert + idempotent)
        #   - convert_attention_masks_to_bool (rewrite + no-op)
        #   - create_new_function happy-path (versioning block, license
        #     header, AST parse, importlib re-import)
        #   - create_new_function **kwargs collision (exercises
        #     _rewrite_kwargs_param + _insert_kwargs_alias)
        #   - UNSLOTH_COMPILE_OVERWRITE=0 forced-recompile on transformers
        #     version mismatch (compiler.py:947-963)
        #   - matching short-circuit when versions are equal
        # No real transformers modeling module is loaded; complements the
        # heavier real-class round-trip step below. Wall-time ~10-25s.
        run: |
          set -euxo pipefail
          cat > tests/_compiler_cache_invariants_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          # Cache-hygiene + source-rewriter invariants for unsloth_zoo.compiler.
          import sys, pathlib, os, ast, importlib, importlib.util, time
          sys.path.insert(0, str(pathlib.Path(__file__).parent))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()
          import pytest
          import torch  # noqa: F401  (compiler.py imports torch at module load)


          def _isolate_cache(tmp_path, monkeypatch):
              """Point UNSLOTH_COMPILE_LOCATION at tmp_path and reset module
              globals. The compiler.py global is captured at module load
              (line 75/179), so we delete + reimport per test."""
              monkeypatch.setenv("UNSLOTH_COMPILE_LOCATION", str(tmp_path))
              if "unsloth_zoo.compiler" in sys.modules:
                  del sys.modules["unsloth_zoo.compiler"]
              import unsloth_zoo.compiler as compiler
              compiler.UNSLOTH_COMPILE_LOCATION = str(tmp_path)
              compiler.UNSLOTH_COMPILE_USE_TEMP = False
              return compiler


          def test_higher_precision_softmax_basic_and_idempotent(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              src = (
                  "y = nn.functional.softmax(x, dim=-1)\n"
                  "z = F.softmax(a, dim=1, dtype=torch.bfloat16)\n"
              )
              out = c.higher_precision_softmax(src)
              assert "dtype = torch.float32).to(x.dtype)" in out
              assert "dtype = torch.float32).to(a.dtype)" in out
              # Idempotency landed in unslothai/unsloth-zoo#631
              # (negative-lookahead on `.to(<var>.dtype)` so a second
              # pass does not append another cast).
              assert c.higher_precision_softmax(out) == out


          def test_fix_rotary_dtype_no_op_without_env(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              monkeypatch.delenv("UNSLOTH_FORCE_CUSTOM_DTYPE", raising=False)
              src = "out = cos.to(dtype=x.dtype) + sin.to(dtype=x.dtype)\n"
              assert c.fix_rotary_embedding_dtype(src) == src


          def test_fix_rotary_dtype_active(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              monkeypatch.setenv(
                  "UNSLOTH_FORCE_CUSTOM_DTYPE",
                  "float16;torch.float32;torch.bfloat16;torch.float16;pass",
              )
              monkeypatch.setenv("UNSLOTH_FORCE_FLOAT32", "1")
              src = "out = cos.to(dtype=x.dtype) + sin.to(dtype=x.dtype)\n"
              out = c.fix_rotary_embedding_dtype(src)
              # Active form rewrites cos.to / sin.to. Either the conditional
              # form or the cast form is acceptable -- different transformers
              # versions surface slightly different outputs from the rewriter.
              assert "cos.to(dtype=x.dtype)" not in out
              assert "sin.to(dtype=x.dtype)" not in out


          def test_fix_attention_dtype_consistency_insert_then_idempotent(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              src = (
                  "    query_states, key_states = apply_rotary_pos_emb("
                  "query_states, key_states, cos, sin)\n"
                  "    attn = q @ k.T\n"
              )
              out = c.fix_attention_dtype_consistency(src)
              assert out.count("value_states = value_states.to(query_states.dtype)") == 1
              assert c.fix_attention_dtype_consistency(out) == out


          def test_convert_attention_masks_to_bool_rewrites(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              src = (
                  "def make_mask(x):\n"
                  "    out = torch.finfo(x.dtype).min * x\n"
                  "    return out\n"
              )
              out = c.convert_attention_masks_to_bool("make_mask", src)
              # Loose match: rewriter inserts a `!=torch.finfo(...).min` check
              # somewhere on the return path. Tightening to an exact
              # last-line match is brittle across transformers versions.
              assert "!=torch.finfo" in out


          def test_convert_attention_masks_to_bool_no_op(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              src = "def make_mask(x):\n    return x\n"
              assert c.convert_attention_masks_to_bool("make_mask", src) == src


          def _versioning_lines(file_text):
              """Extract the four version strings from the versioning block."""
              assert file_text.startswith('"""\n'), "missing opening triple-quote"
              head = file_text.split("__UNSLOTH_VERSIONING__", 1)[0]
              lines = [ln for ln in head.splitlines() if ln and ln != '"""']
              return lines


          def test_create_new_function_happy_path(tmp_path, monkeypatch):
              c = _isolate_cache(tmp_path, monkeypatch)
              src = "def f(x):\n    return nn.functional.softmax(x, dim=-1)\n"
              c.create_new_function(
                  name="f_happy", new_source=src, model_location="builtins",
                  functions=[], overwrite=True,
              )
              cached = tmp_path / "f_happy.py"
              assert cached.exists()
              text = cached.read_text(encoding="utf-8")
              versions = _versioning_lines(text)
              assert len(versions) == 4, versions
              assert text.count(c._full_license_header) == 1
              ast.parse(text)
              spec = importlib.util.spec_from_file_location("f_happy_reimport", cached)
              m2 = importlib.util.module_from_spec(spec)
              spec.loader.exec_module(m2)
              assert callable(m2.f)
              import inspect as _inspect
              # higher_precision_softmax should have promoted to float32.
              assert "dtype = torch.float32" in _inspect.getsource(m2.f)


          def test_create_new_function_overwrite_zero_recompiles_on_version_mismatch(
              tmp_path, monkeypatch,
          ):
              c = _isolate_cache(tmp_path, monkeypatch)
              name = "vmismatch"
              cached = tmp_path / f"{name}.py"
              stub = (
                  '"""\n0.0.0\n0.0.0\n0.0.0-stub\n0.0.0\n__UNSLOTH_VERSIONING__\n"""\n'
                  + c._full_license_header
                  + "def vmismatch(x):\n    return x\n"
              )
              cached.write_text(stub, encoding="utf-8")
              monkeypatch.setenv("UNSLOTH_COMPILE_OVERWRITE", "0")
              src = "def vmismatch(x):\n    return x + 1\n"
              c.create_new_function(
                  name=name, new_source=src, model_location="builtins",
                  functions=[], overwrite=False,
              )
              text = cached.read_text(encoding="utf-8")
              assert "0.0.0-stub" not in text, (
                  "OVERWRITE=0 + transformers-version-mismatch did NOT recompile"
              )
              versions = _versioning_lines(text)
              import importlib.metadata as _md
              assert versions[2] == _md.version("transformers")


          def test_create_new_function_overwrite_zero_short_circuits_when_versions_match(
              tmp_path, monkeypatch,
          ):
              c = _isolate_cache(tmp_path, monkeypatch)
              name = "vmatch"
              src = "def vmatch(x):\n    return x\n"
              c.create_new_function(
                  name=name, new_source=src, model_location="builtins",
                  functions=[], overwrite=True,
              )
              cached = tmp_path / f"{name}.py"
              mtime_before = cached.stat().st_mtime_ns
              time.sleep(0.05)
              monkeypatch.setenv("UNSLOTH_COMPILE_OVERWRITE", "0")
              c.create_new_function(
                  name=name, new_source=src, model_location="builtins",
                  functions=[], overwrite=False,
              )
              assert cached.stat().st_mtime_ns == mtime_before, (
                  "OVERWRITE=0 + matching versions should NOT rewrite the file"
              )
          PY
          python -m pytest -q --tb=short tests/_compiler_cache_invariants_shim.py
          rm -f tests/_compiler_cache_invariants_shim.py

      - name: Compiler full-model-sweep (every transformers.models.*) + SFT trainer round-trip
        # Calls `unsloth_compile_transformers(model_type=...)` against EVERY
        # `transformers.models.<x>` package the matrix's transformers ships
        # (pkgutil.iter_modules walk -- 383 packages on 4.57.6, similar on
        # latest), then ast.parse / importlib-load / introspect the
        # generated unsloth_compiled_cache/*.py file per model. Catches
        # regex / source-rewriter drift across the matrix's (transformers,
        # trl) combination -- the dominant failure mode of
        # `unsloth_compile_transformers` after a transformers point release.
        #
        # 21 model_types currently break the compiler (verified locally on
        # transformers 4.57.6). They are listed in KNOWN_BROKEN below with
        # their failure mode so the sweep stays green and any NEW breakage
        # surfaces as red. Each entry is tracked for an individual fix
        # PR on unsloth-zoo. The list is split by failure category so
        # follow-up PRs can target one bug at a time.
        #
        # Hermetic cache dir per pytest invocation; we override the
        # job-level UNSLOTH_COMPILE_DISABLE=1 inside the shim so
        # compilation actually runs here. Wall-time estimate ~2-3 min
        # warm (mean ~0.3s/model, 383 models = ~110s on the runner).
        run: |
          set -euxo pipefail
          cat > tests/_zoo_compiler_cache_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          import os, sys, ast, pathlib, importlib.util, tempfile
          _HERE = pathlib.Path(__file__).parent
          sys.path.insert(0, str(_HERE))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()

          # Hermetic cache dir + force compile path. The compiler's
          # globals (UNSLOTH_COMPILE_LOCATION, UNSLOTH_COMPILE_USE_TEMP)
          # are captured at module load; an earlier conftest `import
          # unsloth` may have already imported unsloth_zoo.compiler with
          # the default "unsloth_compiled_cache" path. Mutate the live
          # module globals after import so this shim is robust to that
          # ordering. Otherwise the compiler silently writes to the
          # default cache and the per-model file assertion fails.
          _CACHE = pathlib.Path(tempfile.mkdtemp(prefix="unsloth_cache_"))
          os.environ["UNSLOTH_COMPILE_LOCATION"] = str(_CACHE)
          os.environ["UNSLOTH_COMPILE_OVERWRITE"] = "1"
          os.environ.pop("UNSLOTH_COMPILE_DISABLE", None)

          import pytest
          import unsloth_zoo.compiler as _zoo_compiler
          _zoo_compiler.UNSLOTH_COMPILE_LOCATION = str(_CACHE)
          _zoo_compiler.UNSLOTH_COMPILE_USE_TEMP = False
          from unsloth_zoo.compiler import unsloth_compile_transformers


          def _verify_file(path: pathlib.Path, must_expose):
              assert path.exists(), f"compiler did not write {path}"
              src = path.read_text(encoding="utf-8")
              ast.parse(src, filename=str(path))
              spec = importlib.util.spec_from_file_location(path.stem, path)
              mod = importlib.util.module_from_spec(spec)
              spec.loader.exec_module(mod)
              for name in must_expose:
                  assert hasattr(mod, name), (
                      f"{path.name} missing expected attr {name!r}; "
                      f"found: {sorted(n for n in dir(mod) if not n.startswith('_'))[:25]}"
                  )


          # ---------- Full transformers.models.* compile sweep ----------
          # Track the model_types that currently break the compiler on
          # transformers >=5,<6. After unsloth-zoo#632 landed, transformers
          # 4.57.6 has zero failures across all model_types; the 27 entries
          # below are the residual failures on the tf 5.x line. New breakage
          # on any OTHER model_type fails the cell. Each entry is a
          # tracking item for a follow-up unsloth-zoo PR.
          KNOWN_BROKEN_COMPILE = {
              # Category A: `string index out of range` in source rewriter.
              "colpali":         "string index out of range",
              "colqwen2":        "string index out of range",
              "colmodernvbert":  "string index out of range",
              "dpr":             "string index out of range",
              "gemma4_assistant":"string index out of range",
              "rag":             "string index out of range",
              "shieldgemma2":    "string index out of range",
              "timm_backbone":   "string index out of range",
              # Category B: rewriter emits invalid Python source.
              "clvp":            "emitted file: unexpected indent",
              "falcon_mamba":    "emitted file: unexpected indent",
              "gpt2":            "emitted file: unexpected indent",
              "imagegpt":        "emitted file: unexpected indent",
              "mamba":           "emitted file: unexpected indent",
              "tapas":           "emitted file: expected ':'",
              "xlstm":           "emitted file: unexpected indent",
              # Category B-2: emit unterminated string literal (latest tf).
              "audioflamingo3":  "emitted file: unterminated string literal",
              "musicflamingo":   "emitted file: unterminated string literal",
              "voxtral":         "emitted file: unterminated string literal",
              "voxtral_realtime":"emitted file: unterminated string literal",
              # Category C: rewriter emits unclosed paren.
              "kosmos2":         "emitted file: '(' was never closed",
              "kosmos2_5":       "emitted file: '(' was never closed",
              # Category D: imports list builder picks up a non-exported name.
              "auto":            "module has no attribute _BaseModelWithGenerate",
              "bit":             "module has no attribute Linear",
              "regnet":          "module has no attribute Linear",
              "resnet":          "module has no attribute Linear",
              # Category E: undefined name in emitted file.
              "perceiver":       "name 'AbstractPreprocessor' is not defined",
              "sam3_lite_text":  "name 'Sam3LiteTextLayerScaledResidual' is not defined",
              # Category F: compile exceeds 60s budget on the runner.
              # First seen on transformers >=5,<6; each represents a slow
              # or recursive source-rewriter path the zoo can address.
              "beit":            "TimeoutError: compile exceeds per-model budget",
              "sam":             "TimeoutError: compile exceeds per-model budget",
              "sam_hq":          "TimeoutError: compile exceeds per-model budget",
          }


          def _all_model_types():
              import pkgutil, transformers.models as tm
              return sorted(s.name for s in pkgutil.iter_modules(tm.__path__) if s.ispkg)


          def test_compile_every_transformers_model_type():
              """Run unsloth_compile_transformers across every model_type
              the matrix's transformers ships. Allowed outcomes:
                ok      -> compile emitted a parseable, importable cache file
                skipped -> no `modeling_<x>.py` file (expected for some
                           umbrella packages like `auto`, `deprecated`)
                known   -> in KNOWN_BROKEN_COMPILE; tracked for follow-up.
              Any uncaught failure fails the cell.

              Per-model SIGALRM cap so one infinite-looping model_type
              cannot wedge the whole sweep + nuke the job timeout
              (observed on transformers >=5,<6 -- 30+ min hang before
              this guard landed)."""
              import importlib as _il
              import signal
              ok = 0
              skipped = []
              known = []
              new_failures = []
              models = _all_model_types()
              def _on_timeout(signum, frame):
                  raise TimeoutError("compile exceeded per-model budget")
              prev_handler = signal.signal(signal.SIGALRM, _on_timeout)
              try:
                  for i, model_type in enumerate(models):
                      if i % 25 == 0:
                          print(f"  sweep progress: {i}/{len(models)} -> {model_type}", flush=True)
                      modeling_path = f"transformers.models.{model_type}.modeling_{model_type}"
                      try:
                          _il.import_module(modeling_path)
                      except (ModuleNotFoundError, ImportError):
                          skipped.append((model_type, "no modeling file"))
                          continue
                      signal.alarm(60)
                      try:
                          unsloth_compile_transformers(
                              model_type=model_type, fast_lora_forwards=False,
                          )
                      except Exception as e:
                          signal.alarm(0)
                          msg = f"{type(e).__name__}: {str(e)[:200]}"
                          if model_type in KNOWN_BROKEN_COMPILE:
                              known.append((model_type, msg))
                          else:
                              new_failures.append((model_type, msg))
                          continue
                      signal.alarm(0)
                      if model_type in KNOWN_BROKEN_COMPILE:
                          # Came back green unexpectedly -- that's GOOD news,
                          # the bug was fixed. Surface it so we can drop the
                          # entry from KNOWN_BROKEN_COMPILE.
                          print(
                              f"  UNEXPECTED-OK {model_type}: was in "
                              "KNOWN_BROKEN_COMPILE, now compiles cleanly. "
                              "Drop the entry."
                          )
                      ok += 1
              finally:
                  signal.alarm(0)
                  signal.signal(signal.SIGALRM, prev_handler)
              print(f"\nCompile sweep: ok={ok} skipped={len(skipped)} "
                    f"known-broken={len(known)} new-failures={len(new_failures)}")
              for m, r in known:
                  print(f"  KNOWN  {m}: {r}")
              for m, r in new_failures[:30]:
                  print(f"  NEW    {m}: {r}")
              if len(new_failures) > 30:
                  print(f"  ...and {len(new_failures)-30} more new failures")
              assert not new_failures, (
                  f"unsloth_compile_transformers introduced new failures on "
                  f"{len(new_failures)} model_types not in the known-broken "
                  f"list: {[m for m, _ in new_failures]}"
              )
              # Sanity floor: at least 200 model_types should compile cleanly
              # (we observed 362 ok / 383 total on transformers 4.57.6).
              assert ok >= 200, (
                  f"only {ok} model_types compiled cleanly; expected >=200. "
                  "Possible transformers-version-induced regression."
              )


          @pytest.mark.parametrize("model_type,rms_class", [
              ("llama", "LlamaRMSNorm"),
              ("qwen3", "Qwen3RMSNorm"),
              ("gemma3", "Gemma3RMSNorm"),
          ])
          def test_compile_real_modeling_module(model_type, rms_class):
              """Spot-check on the three production-relevant families that
              the compile_every sweep also covers; this case verifies the
              emitted cache file has the model-specific RMSNorm class
              attribute, not just that the file parses + imports.

              ``unsloth_compile_transformers`` is not idempotent in-
              process: calling it twice on the same modeling module
              after rewriting class attributes corrupts the inspect
              source/line cache and the second emitted file is malformed
              Python. The sweep above already produced a valid cache
              file for every non-KNOWN_BROKEN model_type, so just verify
              that artefact here. Trigger a compile only when running
              this test in isolation (no sweep preceded)."""
              import importlib as _il
              try:
                  modeling = _il.import_module(
                      f"transformers.models.{model_type}.modeling_{model_type}"
                  )
              except ModuleNotFoundError:
                  pytest.skip(
                      f"transformers build lacks model_type={model_type}"
                  )
              combined = _CACHE / f"unsloth_compiled_module_{model_type}.py"
              if not combined.exists():
                  unsloth_compile_transformers(
                      model_type=model_type, fast_lora_forwards=False,
                  )
                  modeling = _il.import_module(
                      f"transformers.models.{model_type}.modeling_{model_type}"
                  )
              assert getattr(modeling, "__UNSLOTH_PATCHED__", False) is True
              _verify_file(combined, must_expose=[rms_class])


          def test_compile_disable_writes_nothing():
              """Negative control: when UNSLOTH_COMPILE_DISABLE=1 the
              compile path must early-return without producing new files."""
              os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"
              try:
                  before = set(_CACHE.iterdir())
                  # Pick a model_type that still resolves on this transformers.
                  for mt in ("llama", "mistral", "qwen2"):
                      try:
                          import importlib as _il
                          _il.import_module(
                              f"transformers.models.{mt}.modeling_{mt}"
                          )
                          break
                      except ModuleNotFoundError:
                          continue
                  else:
                      pytest.skip("no probe model_type available")
                  unsloth_compile_transformers(
                      model_type=mt, fast_lora_forwards=False,
                  )
                  after = set(_CACHE.iterdir())
                  assert after == before, (
                      f"DISABLE=1 still wrote: {[p.name for p in after - before]}"
                  )
              finally:
                  os.environ.pop("UNSLOTH_COMPILE_DISABLE", None)


          def test_compile_sft_trainer_patch():
              """Round-trip TRL's SFTTrainer through the rl.py patch path
              and verify the generated UnslothSFTTrainer.py."""
              pytest.importorskip("trl")
              try:
                  from unsloth.models.rl import _patch_trl_rl_trainers
              except ImportError:
                  pytest.skip("unsloth.models.rl._patch_trl_rl_trainers absent")
              try:
                  _patch_trl_rl_trainers("sft_trainer")
              except Exception as e:
                  # TRL 1.x renames break the patch helper internally; we
                  # accept that here and skip rather than fail the cell.
                  pytest.skip(f"_patch_trl_rl_trainers raised: {type(e).__name__}: {e}")
              sft = _CACHE / "UnslothSFTTrainer.py"
              if not sft.exists():
                  pytest.skip(
                      "_patch_trl_rl_trainers ran but did not emit "
                      "UnslothSFTTrainer.py on this TRL version."
                  )
              _verify_file(sft, must_expose=["UnslothSFTTrainer"])
          PY
          python -m pytest -q --tb=short tests/_zoo_compiler_cache_shim.py
          rm -f tests/_zoo_compiler_cache_shim.py

      - name: TRL trainer + Config auto-discovery + dynamic patch coverage
        # Mirror unsloth/models/rl.py:patch_trl_rl_trainers AND verify the
        # dynamic per-version patch surface:
        #   1. AST-parse every *_trainer / *_config submodule.
        #   2. Apply the same *Trainer / *Config discovery rules
        #      _patch_trl_rl_trainers uses (rl.py:553-620).
        #   3. Orphan check: every <x>_trainer must have a sibling
        #      <x>_config OR an inline *Config.
        #   4. Dynamic count: enumerate every canonical trainer that
        #      imports cleanly, run patch_trl_rl_trainers(), assert
        #      every one ends up Unsloth-prefixed in-place. Floor matches
        #      the cohort sizes from the version sweep:
        #        TRL 0.22-0.23 -> 18 canonical trainers
        #        TRL 0.24-0.28 -> 15 canonical trainers
        #        TRL 0.29-1.x  ->  6 canonical (rest are experimental
        #                          thin-wrappers; covered next)
        #   5. Experimental coverage (TRL 0.29+): walk trl.experimental.*,
        #      find every *Trainer class, verify the umbrella patch
        #      reaches them via the thin-wrapper MRO walk in
        #      _patch_trl_rl_trainers (rl.py:677-702).
        # Per-cell wall-time ~30-60s.
        run: |
          set -euxo pipefail
          cat > tests/_trl_trainer_discovery_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          # Walks every *_trainer / *_config module in trl.trainer and
          # validates that unsloth's auto-discovery rules in
          # unsloth/models/rl.py:_patch_trl_rl_trainers (lines 542-620,
          # 1934-1949) still pick out exactly one *Trainer and one
          # *Config per module on the matrix's TRL version.
          import sys, pathlib, importlib, importlib.util, ast, inspect

          sys.path.insert(0, str(pathlib.Path(__file__).parent))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()

          import pytest
          pytest.importorskip("trl")
          import trl  # noqa: F401  (forces lazy-module init)
          import trl.trainer


          def _is_real_submodule(qual_name: str) -> bool:
              """True iff `qual_name` resolves to an importable submodule
              with a file on disk (i.e. has a non-None find_spec().origin).

              TRL re-exports utility FUNCTIONS into `trl.trainer.__init__`
              whose names happen to end with `_config` (e.g.
              `get_peft_config`, `get_quantization_config`). Without this
              filter the `endswith` check below picks them up as if they
              were submodules and the AST stage fails on `no spec`. The
              same trap exists for `_trainer` (none today, but defensive).
              """
              try:
                  spec = importlib.util.find_spec(qual_name)
              except (ImportError, ValueError):
                  return False
              return spec is not None and bool(getattr(spec, "origin", None))


          # Replicate rl.py:1939-1943 verbatim, then filter to actual
          # submodules so re-exported utility functions (e.g.
          # `get_peft_config`) do not pollute the AST sweep.
          def _trainer_files():
              return [
                  x for x in dir(trl.trainer)
                  if x.islower()
                  and x.endswith("_trainer")
                  and x != "base_trainer"
                  and _is_real_submodule(f"trl.trainer.{x}")
              ]


          def _config_files():
              return [
                  x for x in dir(trl.trainer)
                  if x.islower()
                  and x.endswith("_config")
                  and _is_real_submodule(f"trl.trainer.{x}")
              ]


          def _ast_parse_module_via_spec(qual_name: str):
              """AST-parse a module's source on disk WITHOUT importing it.
              `trl.trainer` uses _LazyModule so `find_spec` resolves the
              file path without firing the module-level `__init__`. This
              dodges optional-dep ImportErrors (e.g. grpo_trainer's vllm
              import) and still surfaces real syntax drift in the file."""
              spec = importlib.util.find_spec(qual_name)
              if spec is None or not spec.origin:
                  return None, "no spec"
              path = pathlib.Path(spec.origin)
              if not path.is_file():
                  return None, f"spec.origin not a file: {path}"
              src = path.read_text(encoding="utf-8")
              ast.parse(src, filename=str(path))
              return path, None


          def test_every_trl_trainer_and_config_module_ast_parses():
              """Stage 1: pure file-on-disk AST parse. Catches a TRL
              source-level syntax issue on any matrix cell without
              triggering optional-dep imports."""
              fail = []
              ok = 0
              for name in _trainer_files() + _config_files():
                  qual = f"trl.trainer.{name}"
                  try:
                      path, err = _ast_parse_module_via_spec(qual)
                      if err:
                          fail.append((qual, err))
                      else:
                          ok += 1
                  except SyntaxError as e:
                      fail.append((qual, f"SyntaxError: {e}"))
                  except Exception as e:
                      fail.append((qual, f"{type(e).__name__}: {e}"))
              print(f"AST-parsed {ok} TRL trainer+config modules; failed={len(fail)}")
              for q, e in fail:
                  print(f"  AST FAIL {q}: {e}")
              assert not fail, f"AST parse failed for {len(fail)} TRL modules"


          def _apply_unsloth_discovery_rules(mod, trainer_file):
              """Replicate the four endswith filters in
              rl.py:553-569 verbatim."""
              prefix = trainer_file.split("_")[0]
              names = [
                  x for x in dir(mod)
                  if x.endswith("Trainer") and x != "Trainer"
                  and not x.startswith("_") and prefix in x.lower()
              ]
              configs = [
                  x for x in dir(mod)
                  if x.endswith("Config") and x != "Config"
                  and not x.startswith("_") and prefix in x.lower()
              ]
              return names, configs


          def _resolve_config_via_fallbacks(trainer_file, name_list, mod):
              """Replicate rl.py:575-615: try the sibling *_config.py
              module, then the MRO walk fallback. Returns the resolved
              config-name list (length 0 or 1)."""
              # Fallback 1: <prefix>_config.py module sibling.
              cfg_module_name = trainer_file.replace("_trainer", "_config")
              try:
                  cfg_mod = getattr(trl.trainer, cfg_module_name)
              except Exception:
                  cfg_mod = None
              if cfg_mod is not None:
                  prefix = trainer_file.split("_")[0]
                  hits = [
                      x for x in dir(cfg_mod)
                      if x.endswith("Config") and x != "Config"
                      and not x.startswith("_") and prefix in x.lower()
                  ]
                  if len(hits) == 1:
                      return hits
              # Fallback 2: MRO walk into experimental parent module.
              if len(name_list) != 1:
                  return []
              try:
                  trainer_cls = getattr(mod, name_list[0])
              except Exception:
                  return []
              prefix = trainer_file.split("_")[0]
              for parent in trainer_cls.__mro__[1:]:
                  if parent is object:
                      continue
                  parent_mod = inspect.getmodule(parent)
                  if parent_mod is None:
                      continue
                  if parent_mod.__name__ == f"trl.trainer.{trainer_file}":
                      continue
                  hits = [
                      x for x in dir(parent_mod)
                      if x.endswith("Config") and x != "Config"
                      and not x.startswith("_") and prefix in x.lower()
                  ]
                  if len(hits) == 1:
                      return hits
              return []


          def test_unsloth_auto_discovery_finds_trainer_and_config_per_module():
              """Stage 2: drive the same unsloth rules over every trainer
              file. import-failures (optional deps) are recorded as
              `import-skipped`, mirroring rl.py:1944-1948 try/except."""
              ok = 0
              import_skipped = []
              discovery_skipped = []
              fail = []
              for trainer_file in _trainer_files():
                  qual = f"trl.trainer.{trainer_file}"
                  try:
                      mod = getattr(trl.trainer, trainer_file)
                  except Exception as e:
                      import_skipped.append((qual, f"{type(e).__name__}: {e}"))
                      continue
                  trainers, configs = _apply_unsloth_discovery_rules(
                      mod, trainer_file,
                  )
                  if len(trainers) != 1:
                      discovery_skipped.append(
                          (qual, f"trainers={trainers}")
                      )
                      continue
                  if len(configs) != 1:
                      configs = _resolve_config_via_fallbacks(
                          trainer_file, trainers, mod,
                      )
                  if len(configs) != 1:
                      fail.append(
                          (qual,
                           f"trainer={trainers[0]} but config not found "
                           "(checked module, *_config sibling, and MRO)")
                      )
                      continue
                  ok += 1
                  print(f"  OK {qual}: trainer={trainers[0]}, config={configs[0]}")
              print(
                  f"\nDiscovery: ok={ok} import_skipped={len(import_skipped)} "
                  f"discovery_skipped={len(discovery_skipped)} fail={len(fail)}"
              )
              for q, r in import_skipped:
                  print(f"  IMPORT-SKIP {q}: {r}")
              for q, r in discovery_skipped:
                  print(f"  DISC-SKIP   {q}: {r}")
              for q, r in fail:
                  print(f"  FAIL        {q}: {r}")
              # Hard contract: every TRAINER that imports cleanly AND has
              # exactly one *Trainer must also resolve exactly one *Config
              # via one of the three rules. import-skipped + discovery-
              # skipped (no/multiple *Trainer) are tolerated.
              assert not fail, (
                  f"unsloth discovery rules failed for {len(fail)} trainers"
              )
              # Sanity: at least 3 trainers should fully discover on any
              # matrix cell (sft + reward + dpo are the historical core).
              assert ok >= 3, (
                  f"only {ok} trainers fully discovered; expected >=3 "
                  "(sft/reward/dpo). Possible TRL surface regression."
              )


          def test_orphan_trainer_modules_do_not_exist():
              """Stage 3: every <x>_trainer module should have a sibling
              <x>_config (TRL 0.26+ convention) OR an inline *Config. An
              ORPHAN <x>_trainer with neither is a TRL refactor we want
              to know about: it would silently break unsloth's
              auto-discovery without raising."""
              orphans = []
              for trainer_file in _trainer_files():
                  cfg_module_name = trainer_file.replace("_trainer", "_config")
                  has_sibling_cfg = (
                      importlib.util.find_spec(
                          f"trl.trainer.{cfg_module_name}"
                      ) is not None
                  )
                  if has_sibling_cfg:
                      continue
                  # No sibling -> require an inline *Config in the
                  # trainer module itself (resolved via discovery rules).
                  try:
                      mod = getattr(trl.trainer, trainer_file)
                  except Exception:
                      # Optional-dep failure -> skip; the AST-parse stage
                      # already covered the file.
                      continue
                  _, configs = _apply_unsloth_discovery_rules(
                      mod, trainer_file,
                  )
                  if not configs:
                      orphans.append(trainer_file)
              assert not orphans, (
                  "Orphan TRL trainer modules with neither sibling "
                  f"<x>_config.py nor an inline *Config: {orphans}. "
                  "unsloth auto-discovery would silently skip these."
              )


          # ---- Dynamic patch coverage: count + verify Unsloth-prefixed ----

          def _enumerate_canonical_trainer_classes():
              """Walk trl.trainer/*_trainer.py on disk (the source of
              truth for what `dir(trl.trainer)` should expose) and return
              [(trainer_file, TrainerClass), ...] for every entry that
              imports + has exactly-one resolvable *Trainer per the
              unsloth rules. Skips optional-dep ImportErrors."""
              out = []
              for trainer_file in _trainer_files():
                  try:
                      mod = getattr(trl.trainer, trainer_file)
                  except Exception:
                      continue
                  trainers, _ = _apply_unsloth_discovery_rules(mod, trainer_file)
                  if len(trainers) != 1:
                      continue
                  try:
                      cls = getattr(mod, trainers[0])
                  except Exception:
                      continue
                  out.append((trainer_file, cls))
              return out


          def _enumerate_experimental_trainer_packages():
              """TRL 0.29+ moved many trainers (bco, cpo, gkd, nash_md,
              online_dpo, orpo, ppo, prm, xpo, ...) to `trl.experimental.<pkg>`,
              re-exposing them via thin-wrapper deprecation shims in
              `trl.trainer.<x>_trainer`. List every `trl.experimental.<pkg>`
              that defines at least one *Trainer class, parsed by AST so we
              do NOT trigger the optional-dep imports on the package init."""
              spec = importlib.util.find_spec("trl.experimental")
              if spec is None or not spec.submodule_search_locations:
                  return []
              import re as _re
              hits = []
              for root in spec.submodule_search_locations:
                  rp = pathlib.Path(root)
                  for sub in sorted(rp.iterdir()):
                      if not sub.is_dir() or sub.name.startswith("_"):
                          continue
                      classes = []
                      for py in sub.rglob("*.py"):
                          try:
                              src = py.read_text(encoding="utf-8")
                          except Exception:
                              continue
                          for m in _re.finditer(
                              r"^class\s+([A-Za-z0-9_]+Trainer)\b", src, _re.M,
                          ):
                              classes.append(m.group(1))
                      if classes:
                          hits.append((sub.name, sorted(set(classes))))
              return hits


          def _is_unsloth_patched(cls) -> bool:
              return getattr(cls, "__name__", "").startswith("Unsloth")


          def test_unsloth_patches_every_canonical_trainer_in_this_trl_version():
              """Verify the count + identity of canonically-patched trainers
              matches the trainer surface this TRL version actually ships.

              For TRL 0.22.x-0.23.x: ~18 canonical trainers expected.
              For TRL 0.24.x-0.28.x: ~15 canonical trainers expected.
              For TRL 0.29.x-1.x:    6 canonical (rest are experimental
              thin-wrappers; covered by the next test)."""
              from unsloth.models.rl import patch_trl_rl_trainers
              before = _enumerate_canonical_trainer_classes()
              before_count = len(before)
              before_unpatched = [
                  (tf, cls.__name__) for tf, cls in before
                  if not _is_unsloth_patched(cls)
              ]
              # Apply unsloth's umbrella patch.
              patch_trl_rl_trainers()
              # Re-enumerate (some classes may have been replaced in-module).
              after = _enumerate_canonical_trainer_classes()
              after_count = len(after)
              patched = [(tf, cls.__name__) for tf, cls in after
                         if _is_unsloth_patched(cls)]
              unpatched = [(tf, cls.__name__) for tf, cls in after
                           if not _is_unsloth_patched(cls)]
              print(
                  f"\nCanonical trainer surface for TRL {trl.__version__}: "
                  f"discoverable_before={before_count} "
                  f"discoverable_after={after_count} "
                  f"patched={len(patched)} unpatched={len(unpatched)}"
              )
              for tf, n in patched:
                  print(f"  PATCHED   {tf}: {n}")
              for tf, n in unpatched:
                  print(f"  UNPATCHED {tf}: {n}")
              # Hard contract: every canonical trainer that imports
              # cleanly must end up Unsloth-prefixed after the umbrella
              # patch. If a trainer was discoverable BEFORE the patch but
              # is missing from `after`, that is a separate (rare) issue
              # we surface as failure.
              assert before_count == after_count, (
                  f"trainer-class set changed across patching: "
                  f"before={[n for _, n in before_unpatched]} "
                  f"after={[n for _, n in unpatched]}"
              )
              assert not unpatched, (
                  "unsloth.models.rl.patch_trl_rl_trainers did NOT patch: "
                  + ", ".join(f"{tf}:{n}" for tf, n in unpatched)
              )
              # Floor matches the cohort sizes from the TRL version sweep:
              # 18 (0.22-0.23), 15 (0.24-0.28), 6 (0.29+ canonical only).
              assert len(patched) >= 6, (
                  f"only {len(patched)} canonical trainers patched; "
                  "expected >= 6 (the smallest production cohort)."
              )


          def test_unsloth_patches_experimental_trainers_via_thin_wrappers():
              """TRL 0.29+ ships canonical-`trl.trainer.<x>_trainer` modules
              for many trainers as deprecation thin-wrappers that forward
              to `trl.experimental.<x>`. unsloth's
              `_patch_trl_rl_trainers` (rl.py:677-702) detects
              `trl.experimental` in the trainer source and resolves to
              the parent class -- so patching the canonical entry should
              also Unsloth-prefix the experimental class via in-module
              setattr.

              Verify by walking trl.experimental.* AST for every *Trainer
              class, then checking whether it (or any class with the same
              name in the experimental package) carries the Unsloth
              prefix after the umbrella patch."""
              from unsloth.models.rl import patch_trl_rl_trainers
              patch_trl_rl_trainers()
              experimental_pkgs = _enumerate_experimental_trainer_packages()
              if not experimental_pkgs:
                  pytest.skip(
                      f"TRL {trl.__version__} has no trl.experimental.* "
                      "trainer surface (pre-0.29 cohort). The canonical "
                      "test above already covers patching here."
                  )
              found = []
              missing = []
              for pkg_name, class_names in experimental_pkgs:
                  qual = f"trl.experimental.{pkg_name}"
                  try:
                      pkg_mod = importlib.import_module(qual)
                  except Exception as e:
                      # Optional-dep ImportError: experimental package
                      # could not be loaded. Match unsloth's runtime
                      # tolerance: this would also be silently skipped
                      # by `_patch_trl_rl_trainers`. Record but do not
                      # fail.
                      print(
                          f"  IMPORT-SKIP {qual}: "
                          f"{type(e).__name__}: {str(e)[:120]}"
                      )
                      continue
                  for cls_name in class_names:
                      cls = getattr(pkg_mod, cls_name, None)
                      if cls is None:
                          # Class is defined inside the package but not
                          # re-exported on the package init. Walk
                          # submodules to find it.
                          import pkgutil as _pku
                          for sub in _pku.walk_packages(
                              pkg_mod.__path__, prefix=qual + "."
                          ):
                              try:
                                  sub_mod = importlib.import_module(sub.name)
                              except Exception:
                                  continue
                              cls = getattr(sub_mod, cls_name, None)
                              if cls is not None:
                                  break
                      if cls is None:
                          missing.append((pkg_name, cls_name))
                          continue
                      if _is_unsloth_patched(cls):
                          found.append((pkg_name, cls_name))
                          print(f"  PATCHED   trl.experimental.{pkg_name}.{cls_name}")
                      else:
                          # Not Unsloth-prefixed: either unsloth chose
                          # not to patch this surface (e.g. the canonical
                          # thin-wrapper module did not exist) or the
                          # patch silently failed. Record both
                          # outcomes; the assertion below tolerates the
                          # gap as informational, not failure -- the
                          # canonical test enforces the hard contract.
                          print(
                              f"  NOT-PATCHED trl.experimental.{pkg_name}."
                              f"{cls_name} (no Unsloth-prefix on the "
                              "experimental surface)"
                          )
              total_experimental = sum(len(cs) for _, cs in experimental_pkgs)
              print(
                  f"\nExperimental trainer surface (TRL {trl.__version__}): "
                  f"{len(experimental_pkgs)} packages, "
                  f"{total_experimental} *Trainer classes; "
                  f"unsloth-patched={len(found)} class-missing={len(missing)}"
              )
              # Hard contract: a *Trainer class declared in a python
              # source file must be locatable in its package after import.
              # If we saw the class definition but cannot find the symbol
              # at runtime, the package's public surface drifted.
              assert not missing, (
                  "experimental *Trainer classes declared in source but "
                  f"not importable: {missing}"
              )
          PY
          python -m pytest -q --tb=short -s tests/_trl_trainer_discovery_shim.py
          rm -f tests/_trl_trainer_discovery_shim.py

      - name: MoE per-family coverage + GRPO patches + grouped_gemm AST
        # Catches the recurring class of bugs that PR #624 (gemma4 missing
        # extractor), PR #612 (gemma4 GRPO patch silently dropped), PR #607
        # (gate_up LoRA dropped from grad graph), PR #601 (qwen MoE shape
        # mismatch), unsloth#4934 (TRL disable_gradient_checkpointing
        # corrupts unsloth GC), and unsloth#3598 (gradient_accumulation
        # double-scale on accepts_loss_kwargs=False) targeted. Coverage:
        #
        #   1. Per-MoE-family side-effect contract: for every patch_*_moe
        #      function in unsloth_zoo.temporary_patches, if its target
        #      transformers class is importable on this matrix cell, the
        #      patch must mark the class with `_unsloth_already_patched=True`
        #      after running. This is exactly what unsloth_zoo's existing
        #      test_moe_lora_extractor_coverage walks at the registration
        #      level; here we tie each patch fn to its declared target so a
        #      silent early-return (PR #612 style) surfaces as red rather
        #      than a coverage skip.
        #
        #   2. PR #4934 (GRPO + TRL 1.0): patch_trl_disable_gradient_checkpointing
        #      must rebind trl.models.utils.disable_gradient_checkpointing to
        #      the unsloth no-op AND propagate the rebinding to every trl.*
        #      module that imported the symbol by reference.
        #
        #   3. PR #3598 (gradient_accumulation): patch_gradient_accumulation_fix
        #      must run cleanly on a synthetic Trainer whose training_step
        #      signature carries `num_items_in_batch`. The original bug was
        #      that `accepts_loss_kwargs=False` (Qwen3VL, Gemma3 in t-4.57)
        #      caused double loss-scaling; here we verify the rewrite path
        #      itself does not raise on a CPU-resolvable shape.
        #
        #   4. unsloth/kernels/moe/grouped_gemm AST smoke: the Triton kernels
        #      are GPU-only at runtime, but a SyntaxError or stray
        #      string-literal in the source still surfaces as a test-time
        #      ImportError on every install. ast.parse the .py files without
        #      executing.
        #
        # Wall-time per cell ~30-60s. Routed through pytest for the spoof
        # harness so unsloth_zoo.temporary_patches imports are clean.
        run: |
          set -euxo pipefail
          cat > tests/_moe_coverage_shim.py <<'PY'
          # Auto-generated by .github/workflows/consolidated-tests-ci.yml.
          import sys, pathlib, ast, importlib, importlib.util, contextlib, os
          sys.path.insert(0, str(pathlib.Path(__file__).parent))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()

          import pytest

          # Map each MoE patch function to the transformers classes it is
          # contractually responsible for marking with _unsloth_already_patched
          # after a successful run. Sourced from
          # unsloth_zoo/temporary_patches/<family>_moe.py:
          #   - qwen3_moe.py:382-398 patches Qwen3MoeExperts (new path) or
          #     Qwen3MoeSparseMoeBlock (old path).
          #   - qwen3_5_moe.py + qwen3_next_moe.py + qwen3_vl_moe.py register
          #     extractors on Qwen3_5MoeExperts / Qwen3NextExperts /
          #     Qwen3VLMoeTextExperts respectively.
          #   - gemma4_moe.py marks Gemma4TextExperts (current) or
          #     Gemma4TextMoEBlock (legacy).
          #   - glm4_moe.py marks Glm4MoeLiteNaiveMoe.
          #   - deepseek_v3_moe.py marks DeepseekV3NaiveMoe.
          #   - gpt_oss.py:patch_gpt_oss_moe_for_lora marks GptOssExperts.
          # Each cell skips a target if the transformers version lacks it
          # (legitimate version-skew); only patches with at least one
          # importable target are exercised.
          # Each entry = ((patch_module, patch_fn), targets, env_setup,
          # version_gate). env_setup runs before the patch fn (e.g. set
          # UNSLOTH_MODEL_NAME for gpt_oss). version_gate is a callable
          # returning True when the patch SHOULD run on this transformers;
          # if False, the test skips with a documented reason.
          def _v5_or_later():
              try:
                  import transformers
                  major = int(transformers.__version__.split(".")[0])
                  return major >= 5
              except Exception:
                  return False

          MOE_PATCHES = [
              {
                  "module": "unsloth_zoo.temporary_patches.qwen3_moe",
                  "fn": "patch_qwen3_moe",
                  "targets": [
                      ("transformers.models.qwen3_moe.modeling_qwen3_moe", "Qwen3MoeExperts"),
                      ("transformers.models.qwen3_moe.modeling_qwen3_moe", "Qwen3MoeSparseMoeBlock"),
                  ],
                  "env": {},
                  "gate": lambda: True,
                  "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.qwen3_5_moe",
                  "fn": "patch_qwen3_5_moe",
                  "targets": [
                      ("transformers.models.qwen3_5_moe.modeling_qwen3_5_moe", "Qwen3_5MoeExperts"),
                  ],
                  "env": {}, "gate": lambda: True, "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.qwen3_next_moe",
                  "fn": "patch_qwen3_next_moe",
                  "targets": [
                      ("transformers.models.qwen3_next.modeling_qwen3_next", "Qwen3NextExperts"),
                  ],
                  "env": {}, "gate": lambda: True, "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.qwen3_vl_moe",
                  "fn": "patch_qwen3_vl_moe",
                  "targets": [
                      ("transformers.models.qwen3_vl_moe.modeling_qwen3_vl_moe", "Qwen3VLMoeTextExperts"),
                  ],
                  "env": {}, "gate": lambda: True, "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.gemma4_moe",
                  "fn": "patch_gemma4_moe",
                  "targets": [
                      ("transformers.models.gemma4.modeling_gemma4", "Gemma4TextExperts"),
                  ],
                  "env": {}, "gate": lambda: True, "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.glm4_moe",
                  "fn": "patch_glm4_moe",
                  "targets": [
                      ("transformers.models.glm4_moe.modeling_glm4_moe", "Glm4MoeLiteNaiveMoe"),
                  ],
                  "env": {}, "gate": lambda: True, "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.deepseek_v3_moe",
                  "fn": "patch_deepseek_v3_moe",
                  "targets": [
                      ("transformers.models.deepseek_v3.modeling_deepseek_v3", "DeepseekV3NaiveMoe"),
                  ],
                  "env": {}, "gate": lambda: True, "gate_reason": "",
              },
              {
                  "module": "unsloth_zoo.temporary_patches.gpt_oss",
                  "fn": "patch_gpt_oss_moe_for_lora",
                  "targets": [
                      ("transformers.models.gpt_oss.modeling_gpt_oss", "GptOssExperts"),
                  ],
                  # The patch reads UNSLOTH_MODEL_NAME and only runs when
                  # "gpt_oss" is in the normalized form. Set it explicitly
                  # so the gate at gpt_oss.py:1387 passes; otherwise the
                  # patch silently early-returns and the test would
                  # spuriously fail.
                  "env": {"UNSLOTH_MODEL_NAME": "gpt_oss"},
                  # Additionally only runs on transformers >= 5
                  # (gpt_oss.py:1392 `_is_transformers_v5()` gate).
                  "gate": _v5_or_later,
                  "gate_reason": (
                      "patch_gpt_oss_moe_for_lora gates on "
                      "transformers >= 5 (split-LoRA grouped_mm path)"
                  ),
              },
          ]


          def _resolve_target_classes(targets):
              """Return [(qual, cls), ...] for every importable target."""
              out = []
              for mod_path, cls_name in targets:
                  try:
                      mod = importlib.import_module(mod_path)
                  except Exception:
                      continue
                  cls = getattr(mod, cls_name, None)
                  if cls is None:
                      continue
                  out.append((f"{mod_path}.{cls_name}", cls))
              return out


          @pytest.mark.parametrize(
              "spec",
              MOE_PATCHES,
              ids=lambda s: s["fn"],
          )
          def test_moe_patch_marks_its_target_when_class_present(spec, monkeypatch):
              """If at least one target class is importable AND the
              version gate passes, run the patch fn and assert at least
              one target is marked patched afterwards. Skips when the
              transformers version lacks every target or when the
              version gate blocks the patch (legitimate). Fails on
              silent patch-fn early-returns (PR #612 class of bug)."""
              targets = spec["targets"]
              patch_module = spec["module"]
              patch_name = spec["fn"]
              importable = _resolve_target_classes(targets)
              if not importable:
                  pytest.skip(
                      f"{patch_name}: no target class importable on this "
                      f"transformers (looked for {[c for _, c in targets]})."
                  )
              if not spec["gate"]():
                  pytest.skip(
                      f"{patch_name}: version gate blocks this cell. "
                      f"Reason: {spec['gate_reason']}"
                  )
              for k, v in spec["env"].items():
                  monkeypatch.setenv(k, v)
              try:
                  pmod = importlib.import_module(patch_module)
              except Exception as e:
                  pytest.skip(
                      f"{patch_module} import failed (likely optional dep): "
                      f"{type(e).__name__}: {e}"
                  )
              fn = getattr(pmod, patch_name, None)
              if fn is None or not callable(fn):
                  pytest.skip(f"{patch_module} has no callable {patch_name}")
              try:
                  fn()
              except Exception as e:
                  raise AssertionError(
                      f"{patch_name}() raised on a transformers that "
                      f"DOES ship at least one target class ({importable}). "
                      f"This is the silent-failure mode PR #612 fixed: "
                      f"{type(e).__name__}: {e}"
                  )
              # At least one importable target must now carry SOME marker
              # showing unsloth touched it. Accepted signals (each is set
              # by a different patch flow in unsloth_zoo):
              #   - `_unsloth_already_patched=True`            (gemma4, deepseek_v3, glm4)
              #   - `_unsloth_lora_patched=True`               (gpt_oss_moe_for_lora)
              #   - `_unsloth_lora_extractor_fn` is callable   (qwen3_*, glm4_moe)
              #   - `_original_<modeling_tail>_<ClassName>_forward` attr
              #     (set by patch_function: qwen3_moe SparseMoeBlock, etc.)
              #   - `_original_forward` attribute              (gpt_oss in-place patch)
              # Accept any one as "patched".
              def _is_patched(cls) -> bool:
                  if getattr(cls, "_unsloth_already_patched", False) is True:
                      return True
                  if getattr(cls, "_unsloth_lora_patched", False) is True:
                      return True
                  if callable(getattr(cls, "_unsloth_lora_extractor_fn", None)):
                      return True
                  if "_original_forward" in dir(cls):
                      return True
                  cls_name = cls.__name__
                  for attr in dir(cls):
                      if attr.startswith("_original_") and attr.endswith(
                          f"_{cls_name}_forward"
                      ):
                          return True
                  return False

              after = _resolve_target_classes(targets)
              marked = [qual for qual, cls in after if _is_patched(cls)]
              if not marked:
                  raise AssertionError(
                      f"{patch_name}() ran without exception but no target "
                      f"in {importable} carries any of the unsloth markers "
                      "(_unsloth_already_patched / _unsloth_lora_patched / "
                      "_unsloth_lora_extractor_fn / _original_*_forward). "
                      "Patch silently no-op'd (PR #612 class of bug)."
                  )
              print(f"  {patch_name}: marked {marked}")


          # ---- PR #4934 (TRL 1.0+ GRPO disable_gradient_checkpointing) ----

          def test_patch_trl_disable_gradient_checkpointing():
              """unsloth/models/rl.py:patch_trl_disable_gradient_checkpointing
              must rebind trl.models.utils.disable_gradient_checkpointing to
              the unsloth no-op when TRL >= 1.0. Pre-1.0 TRL has no such
              symbol -> the patch returns early."""
              try:
                  import trl.models.utils as _tmu
              except ImportError:
                  pytest.skip("trl not installed")
              had_symbol = hasattr(_tmu, "disable_gradient_checkpointing")
              try:
                  from unsloth.models.rl import patch_trl_disable_gradient_checkpointing
              except ImportError:
                  pytest.skip(
                      "unsloth.models.rl.patch_trl_disable_gradient_checkpointing "
                      "absent (older unsloth than #4934)"
                  )
              patch_trl_disable_gradient_checkpointing()
              if not had_symbol:
                  # Pre-1.0 TRL: patch is a no-op early-return. Verify
                  # nothing broke.
                  pytest.skip(
                      "TRL pre-1.0 has no disable_gradient_checkpointing; "
                      "patch correctly early-returned."
                  )
              fn = getattr(_tmu, "disable_gradient_checkpointing", None)
              assert fn is not None, (
                  "trl.models.utils.disable_gradient_checkpointing missing "
                  "after patch -- patch removed the symbol entirely?"
              )
              assert getattr(fn, "_unsloth_noop_patched", False) is True, (
                  "trl.models.utils.disable_gradient_checkpointing was NOT "
                  "rebound to the unsloth no-op. PR #4934 regression."
              )
              # PR #4934 also walks sys.modules to rebind trl.* modules
              # that imported the symbol by reference. Verify at least the
              # canonical trainer modules picked up the rebinding when
              # they re-export it.
              import sys
              checked = 0
              missed = []
              for mod_name, mod in list(sys.modules.items()):
                  if not mod_name.startswith("trl."):
                      continue
                  bound = getattr(mod, "disable_gradient_checkpointing", None)
                  if bound is None:
                      continue
                  checked += 1
                  if not getattr(bound, "_unsloth_noop_patched", False):
                      missed.append(mod_name)
              print(f"  rebound disable_gradient_checkpointing in {checked} trl.* modules")
              assert not missed, (
                  "trl.* modules that imported disable_gradient_checkpointing "
                  f"by reference but did not get rebound: {missed}"
              )


          # ---- PR #3598 (gradient_accumulation loss-scaling rewrite) ----

          def test_patch_gradient_accumulation_fix_runs_on_synthetic_trainer():
              """patch_gradient_accumulation_fix rewrites a Trainer's
              `training_step` source via inspect+exec when the signature
              carries `num_items_in_batch`. PR #3598 fixed the rewrite
              path to not double-scale for trainers with
              `accepts_loss_kwargs=False`. Verify the patch fn runs
              without raising on a synthetic Trainer carrying that
              signature."""
              try:
                  from unsloth.models._utils import patch_gradient_accumulation_fix
              except ImportError:
                  pytest.skip(
                      "unsloth.models._utils.patch_gradient_accumulation_fix absent"
                  )
              try:
                  from transformers import Trainer
              except ImportError:
                  pytest.skip("transformers.Trainer absent")
              # The patch reads the live Trainer.training_step source. We
              # exercise the standard transformers.Trainer here -- if the
              # bug is reintroduced in the source rewriter (e.g. broken
              # exec, missing import injection), the patch fn raises.
              try:
                  patch_gradient_accumulation_fix(Trainer)
              except Exception as e:
                  raise AssertionError(
                      "patch_gradient_accumulation_fix raised on a vanilla "
                      f"transformers.Trainer: {type(e).__name__}: {e}"
                  )
              # Idempotency: second call must not raise either (the rewrite
              # adds `_unsloth_training_step` marker so the second call
              # short-circuits per _utils.py:1692-1693).
              patch_gradient_accumulation_fix(Trainer)


          # ---- unsloth/kernels/moe/grouped_gemm AST smoke ----

          def _walk_py_files(root: pathlib.Path):
              for p in root.rglob("*.py"):
                  if "__pycache__" in p.parts:
                      continue
                  yield p


          def test_unsloth_kernels_moe_grouped_gemm_ast_parses():
              """unsloth/kernels/moe/grouped_gemm hosts the Triton MoE
              kernels (GPU-only at runtime). A SyntaxError or stray token
              at the SOURCE level still surfaces as ImportError on every
              install, so AST-parse the .py files without executing."""
              # Locate `unsloth/kernels/moe/grouped_gemm` via the installed
              # `unsloth` package.
              import unsloth as _unsloth
              kernel_root = (
                  pathlib.Path(_unsloth.__file__).parent
                  / "kernels" / "moe" / "grouped_gemm"
              )
              if not kernel_root.exists():
                  pytest.skip(
                      f"{kernel_root} not present in this unsloth checkout."
                  )
              fail = []
              ok = 0
              for p in _walk_py_files(kernel_root):
                  try:
                      ast.parse(p.read_text(encoding="utf-8"), filename=str(p))
                      ok += 1
                  except SyntaxError as e:
                      fail.append((str(p), f"SyntaxError: {e}"))
                  except Exception as e:
                      fail.append((str(p), f"{type(e).__name__}: {e}"))
              print(f"AST-parsed {ok} grouped_gemm files; failed={len(fail)}")
              for path, err in fail:
                  print(f"  AST FAIL {path}: {err}")
              assert not fail, (
                  f"AST parse failed for {len(fail)} grouped_gemm files"
              )
              # Sanity: the directory MUST contain at least the interface
              # + kernels + reference subtrees as documented.
              expected = [
                  "interface.py",
                  "kernels/forward.py",
                  "kernels/backward.py",
                  "reference/moe_block.py",
                  "reference/moe_ops.py",
              ]
              missing = [e for e in expected if not (kernel_root / e).is_file()]
              assert not missing, (
                  "grouped_gemm directory layout regressed; missing: "
                  f"{missing}"
              )
          PY
          python -m pytest -q --tb=short -s tests/_moe_coverage_shim.py
          rm -f tests/_moe_coverage_shim.py

      - name: Summary
        if: always()
        run: |
          echo "::group::Versions"
          python -c "import sys, platform; print(sys.version); print(platform.platform())"
          python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
          python -c "import transformers; print('transformers', transformers.__version__)"
          # `pip show` instead of `import unsloth_zoo` — its __init__ raises
          # without an accelerator and the spoof harness only kicks in under
          # pytest. Cheap and accurate.
          pip show unsloth_zoo
          echo "::endgroup::"
          echo "Consolidated job done. Coverage:"
          echo "  - 16 unsloth Bucket-A tests under tests/saving/ + tests/utils/"
          echo "  - unsloth_zoo @ ${UNSLOTH_ZOO_REF} pytest tests/ (5 GPU cases deselected)"
          echo "  - unsloth_zoo.compiler.test_apply_fused_lm_head"

  llama-cpp-smoke:
    # Standalone llama.cpp build + smoke. Earlier this lived inside every
    # consolidated matrix cell and re-cmake'd llama.cpp ~5 min per cell --
    # 3 cells x 275 s = ~14 min of duplicated CPU on every PR for an
    # artefact that has nothing to do with the (transformers, TRL) combo.
    # `install_llama_cpp` clones ggml-org/llama.cpp at a pinned commit and
    # builds the LLAMA_CPP_TARGETS list; the result is independent of the
    # HF stack version. Run once, gate the PR.
    name: llama.cpp build + smoke
    runs-on: ubuntu-latest
    timeout-minutes: 25
    env:
      UNSLOTH_ZOO_REF: ${{ inputs.unsloth_zoo_ref || 'main' }}
      # Same env contract the matrix cells use: protobuf python parser
      # (transformers' bundled *_pb2.py needs it), studio on PYTHONPATH,
      # compile-disable + UNSLOTH_IS_PRESENT so unsloth_zoo's __init__
      # bootstrap accepts a pure-import.
      PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
      PYTHONPATH: ${{ github.workspace }}/studio
      UNSLOTH_COMPILE_DISABLE: '1'
      UNSLOTH_IS_PRESENT: '1'
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Install runtime deps for unsloth_zoo.llama_cpp
        # unsloth_zoo's `__init__` imports `temporary_patches`, which
        # in turn pulls per-architecture submodules (gemma3n, gemma4,
        # qwen3_*_moe, glm4_moe, deepseek_v3_moe, pixtral, ministral,
        # mxfp4, bitsandbytes, flex_attention_bwd) -- many of those
        # transitively touch transformers and peft / accelerate. Mirror
        # the matrix job's install minus the heavy bits that have no
        # bearing on `install_llama_cpp` itself: studio.txt's FastAPI
        # stack, bitsandbytes (CUDA-only build dependency), triton,
        # mammoth/unpdf (PDF tools), datasets, sqlalchemy/cryptography,
        # pytest (we run no tests). The remaining pin shape matches
        # studio-backend-ci.yml's "Repo tests (CPU)" baseline.
        run: |
          set -euxo pipefail
          python -m pip install --upgrade pip
          # Match the matrix job's torch path so unsloth_zoo's
          # `import torch` resolves to the same CPU build.
          pip install --index-url https://download.pytorch.org/whl/cpu \
            'torch>=2.4,<2.11' 'torchvision<0.26'
          pip install \
            'numpy<3' protobuf sentencepiece \
            requests tqdm psutil packaging safetensors \
            'peft>=0.18,<0.20' 'accelerate>=0.34,<2'
          # transformers + trl come from pyproject.toml's pinned line
          # so this job stays in sync with whatever the consolidated
          # `__from_pyproject__` matrix cell is using.
          pip install transformers trl
          pip install -e . --no-deps

      - name: Clone unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }}
        # Same shallow clone as the matrix job; we install editable so
        # `unsloth_zoo.llama_cpp` resolves to the cloned tree (and any
        # main-branch fixes flow into the smoke without a release).
        run: |
          set -euxo pipefail
          # github.com occasionally 500s on the git fetch; retry so a
          # single upstream blip does not fail CI.
          for attempt in 1 2 3; do
            rm -rf "$RUNNER_TEMP/unsloth-zoo"
            if git clone --depth=1 --branch="$UNSLOTH_ZOO_REF" \
                https://github.com/unslothai/unsloth-zoo \
                "$RUNNER_TEMP/unsloth-zoo"; then
              break
            fi
            if [ "$attempt" -eq 3 ]; then
              echo "::error::git clone unsloth-zoo failed after 3 attempts"
              exit 1
            fi
            delay=$((5 * attempt))
            echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
            sleep "$delay"
          done
          pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
          pip show unsloth_zoo

      - name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
        # Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
        # flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
        # into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
        # (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
        # llama-server) via cmake, then run `llama-cli --help`.
        #
        # This replaces the previous "download upstream prebuilt zip"
        # approach, which silently exited 0 with the message
        # "no ubuntu-x64 prebuilt asset" when ggml-org's release-asset
        # naming drifted (the regex `bin-ubuntu-x64.*\.zip$` no longer
        # matched their current asset names). The build path is the same
        # one Unsloth users hit in production via `model.save_pretrained_gguf`.
        #
        # Wall-time budget: ~3-5 min cold, dominated by cmake build of
        # 5 targets on the runner's 4 cores. Apt-package install is
        # handled by `install_llama_cpp` itself via its
        # `check_build_requirements` -> `install_package` chain.
        run: |
          set -euxo pipefail
          # libssl-dev / libcurl4-openssl-dev are needed by llama.cpp's
          # cmake build for HTTPS support; install up-front so the
          # `install_llama_cpp` requirement-check is a no-op.
          sudo apt-get update -qq
          sudo apt-get install -y -qq build-essential cmake git curl \
            libgomp1 libssl-dev libcurl4-openssl-dev
          python <<'PY'
          import os, shutil, subprocess, sys, pathlib
          # Apply the same CPU spoof the pytest shims use BEFORE any
          # unsloth_zoo import: unsloth_zoo/__init__.py calls
          # device_type.get_device_type() at module load and raises
          # `NotImplementedError: Unsloth cannot find any torch
          # accelerator` on a GPU-less runner. The spoof flips
          # torch.cuda.is_available() to True so the device probe takes
          # the cuda branch; we never actually run CUDA tensor ops in
          # this step (just clone+cmake+--help on the binaries).
          sys.path.insert(0, str(pathlib.Path("tests").resolve()))
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()
          from unsloth_zoo.llama_cpp import (
              install_llama_cpp,
              LLAMA_CPP_DEFAULT_DIR,
              LLAMA_CPP_TARGETS,
          )
          print(f"Unsloth llama.cpp default dir: {LLAMA_CPP_DEFAULT_DIR}")
          print(f"Build targets: {LLAMA_CPP_TARGETS}")
          # install_llama_cpp returns (quantizer_path, converter_script_path).
          # The quantizer's directory is the `llama.cpp` install root, which
          # also holds llama-cli after build/bin/llama-* gets copied up
          # (llama_cpp.py:867-871).
          quantizer, converter = install_llama_cpp(print_output=True)
          assert quantizer and os.path.exists(quantizer), (
              f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
          )
          assert converter and os.path.isfile(converter), (
              f"install_llama_cpp returned converter={converter!r} but missing"
          )
          install_root = os.path.dirname(quantizer)
          cli = os.path.join(install_root, "llama-cli")
          assert os.path.exists(cli), (
              f"llama-cli not found at {cli!r} after build. Build root contents: "
              f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
          )
          assert os.access(cli, os.X_OK), f"{cli!r} not executable"
          # `llama-cli --help` exits non-zero on some builds; the contract
          # is that recognizable help text appears on stdout/stderr.
          proc = subprocess.run(
              [cli, "--help"], capture_output=True, text=True, timeout=30,
          )
          combined = (proc.stdout or "") + (proc.stderr or "")
          print("--- llama-cli --help (first 30 lines) ---")
          print("\n".join(combined.splitlines()[:30]))
          assert any(
              tok in combined.lower()
              for tok in ("usage", "--help", "--model", "-m,")
          ), (
              f"llama-cli --help produced no recognizable help text. "
              f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
              f"stderr: {proc.stderr[:400]!r}"
          )
          # Also exercise the quantizer the way GGUF export does: --help
          # round-trip on the binary that does the actual heavy lifting.
          q = subprocess.run(
              [quantizer, "--help"], capture_output=True, text=True, timeout=15,
          )
          q_combined = (q.stdout or "") + (q.stderr or "")
          assert "usage" in q_combined.lower() or "type" in q_combined.lower(), (
              f"llama-quantize --help produced no help text. "
              f"exit={q.returncode}\nstdout: {q.stdout[:400]!r}\n"
              f"stderr: {q.stderr[:400]!r}"
          )
          print(
              f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
              f"and llama-quantize at {quantizer}."
          )
          PY

```

## /.github/workflows/lint-ci.yml

```yml path="/.github/workflows/lint-ci.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# Whole-repo, multi-language source-lint gate. Runs on every PR
# (no path filter) because each step is sub-second to a few seconds
# and together they catch a class of breakage the focused build
# workflows would miss:
#
#   - Python syntax + ruff + leftover debugger calls (across 350+
#     committed .py files, not just studio/backend).
#   - Shell `bash -n` parse for every committed *.sh.
#   - `yaml.safe_load` and `json.loads` round-trip for every
#     committed YAML / JSON config.
#
# TypeScript and Rust are NOT duplicated here on purpose:
#   - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
#     and `npm run build` (vite/swc) on every studio/frontend/**
#     change, which is a full TS AST + type check.
#   - Studio Tauri CI runs `tauri build --debug --no-bundle` on
#     every studio/src-tauri/** or studio/frontend/** change, which
#     compiles the Rust crate (= cargo check + cargo build).
# Each is a stricter check than a parse-only step would be, so a
# fast-fail duplicate here would only burn cache; the dedicated
# workflows already block merges on Rust / TS regressions.

name: Lint CI

on:
  pull_request:
  push:
    branches: [main, pip]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  source-lint:
    name: Source lint (Python + shell + YAML + JSON + safety nets)
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      # Pin ruff to match .pre-commit-config.yaml so a CI-only ruff
      # bump cannot disagree with what pre-commit accepted.
      # codespell is pinned for the same reason: a reviewer should
      # never see a typo report appear and disappear depending on
      # which codespell version the runner happened to install.
      - run: pip install 'ruff==0.15.12' 'pyyaml>=6' 'codespell>=2.3,<3'

      - name: Linux deps for shellcheck
        run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends shellcheck

      - name: Python AST/syntax check (every committed .py must compile)
        # python -m compileall uses the same parser the interpreter
        # uses, so anything broken here would also crash at
        # `import X` on a user's machine. Sub-second across 350+
        # files. Hard gate.
        run: |
          python -m compileall -q -j 0 \
            unsloth unsloth_cli studio tests cli.py unsloth-cli.py

      - name: Python ruff check (whole repo)
        # The narrow rule set in pyproject.toml [tool.ruff.lint]
        # selects E9 / F63 / F7 / F82 -- syntax errors, broken
        # comparisons, undefined names. The whole repo passes today,
        # so this is a hard gate.
        run: |
          ruff check unsloth unsloth_cli studio tests cli.py unsloth-cli.py

      - name: No leftover debugger / pdb / breakpoint calls
        # Catches the "I'll just stick a breakpoint() here" mistake
        # before it ships. AST-based so commented-out debugger
        # markers don't false-positive (a bare grep would; there
        # are three commented `# breakpoint()` markers in
        # unsloth/models/rl* today). Sub-second.
        run: |
          python <<'PY'
          import ast, pathlib, sys

          SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
                        "unsloth_compiled_cache", "node_modules",
                        "unsloth.egg-info"}

          bad = []
          scanned = 0
          for path in sorted(pathlib.Path(".").rglob("*.py")):
              if any(part in SKIP_PARTS for part in path.parts):
                  continue
              scanned += 1
              try:
                  tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
              except SyntaxError:
                  continue   # compileall step above already failed this
              for node in ast.walk(tree):
                  if not isinstance(node, ast.Call):
                      continue
                  fn = node.func
                  if isinstance(fn, ast.Name) and fn.id == "breakpoint":
                      bad.append((path, node.lineno, "breakpoint()"))
                  elif (isinstance(fn, ast.Attribute) and fn.attr == "set_trace"
                        and isinstance(fn.value, ast.Name)
                        and fn.value.id in {"pdb", "ipdb"}):
                      bad.append((path, node.lineno, f"{fn.value.id}.set_trace()"))

          if bad:
              for path, lineno, what in bad:
                  print(f"::error file={path},line={lineno}::leftover {what} -- remove before merging")
              sys.exit(1)
          print(f"no leftover debugger calls (scanned {scanned} files)")
          PY

      - name: License-header drift (informational; whole repo)
        # Three header families are accepted across the repo:
        #   1. SPDX one-liner: `# SPDX-License-Identifier: ...`
        #      Used across studio/ (AGPL-3.0-only) and a few new
        #      files elsewhere.
        #   2. Apache-2.0 long form, marker phrase
        #      "Licensed under the Apache License". Used across
        #      unsloth/ and unsloth_cli/.
        #   3. GNU long form, marker phrase "General Public License".
        #      That single substring covers GPL, LGPL ("GNU Lesser
        #      General Public License") and AGPL ("GNU Affero
        #      General Public License") preambles, all three of
        #      which appear in unsloth/kernels/* (LGPL/AGPL) without
        #      the SPDX line.
        # Empty files (mainly empty __init__.py) are skipped.
        # Surfaced as a warning; cleaning up the actual misses is a
        # follow-up PR, not a CI fix.
        continue-on-error: true
        run: |
          python <<'PY'
          import pathlib

          ACCEPTED = (
              "SPDX-License-Identifier",        # any SPDX line
              "Licensed under the Apache License",  # Apache-2.0 long form
              "General Public License",         # GPL / LGPL / AGPL long form
          )
          SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
                        "unsloth_compiled_cache", "node_modules",
                        "unsloth.egg-info"}

          studio_missing = []
          other_missing  = []
          for path in sorted(pathlib.Path(".").rglob("*.py")):
              if any(part in SKIP_PARTS for part in path.parts):
                  continue
              text = path.read_text(encoding="utf-8", errors="replace")
              if not text.strip():
                  continue  # empty __init__.py etc.
              head = "\n".join(text.splitlines()[:25])
              if any(marker in head for marker in ACCEPTED):
                  continue
              if "studio" in path.parts:
                  studio_missing.append(path)
              else:
                  other_missing.append(path)

          total = len(studio_missing) + len(other_missing)
          if total == 0:
              print("every committed .py has a recognised license header")
          else:
              print(f"::warning::{total} Python files have no recognised license "
                    f"header (SPDX / Apache-2.0 / GNU long form): "
                    f"studio={len(studio_missing)}, other={len(other_missing)}")
              for path in (studio_missing + other_missing)[:30]:
                  print(f"  {path}")
              if total > 30:
                  print(f"  ... and {total - 30} more")
          PY

      - name: Shell scripts parse cleanly (`bash -n`)
        # Same idea as Python's compileall: parse-only check that
        # every committed *.sh would not blow up at `bash script.sh`
        # invocation time on a release box. tests/sh/ is the largest
        # cluster (the install.sh shape tests).
        run: |
          shopt -s globstar
          fail=0
          for f in $(git ls-files '*.sh'); do
              if ! bash -n "$f"; then
                  echo "::error file=$f::shell parse error"
                  fail=1
              fi
          done
          if [ "$fail" -ne 0 ]; then
              exit 1
          fi
          n=$(git ls-files '*.sh' | wc -l)
          echo "$n shell scripts parse cleanly"

      - name: YAML files parse cleanly (yaml.safe_load)
        # Catches truncated workflow files, broken indents in
        # dependabot.yml / pre-commit configs, etc. Includes
        # .github/workflows/*.yml so a typo in the file we just
        # added shows up immediately.
        run: |
          python <<'PY'
          import pathlib, sys, yaml

          SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
                        "node_modules", "unsloth_compiled_cache",
                        "unsloth.egg-info"}

          bad = []
          scanned = 0
          for path in sorted(list(pathlib.Path(".").rglob("*.yml"))
                             + list(pathlib.Path(".").rglob("*.yaml"))):
              if any(part in SKIP_PARTS for part in path.parts):
                  continue
              scanned += 1
              try:
                  with path.open("r", encoding="utf-8") as fh:
                      list(yaml.safe_load_all(fh))
              except Exception as exc:
                  bad.append((path, exc))

          if bad:
              for path, exc in bad:
                  print(f"::error file={path}::YAML parse failed: {exc}")
              sys.exit(1)
          print(f"{scanned} YAML files parse cleanly")
          PY

      - name: JSON files parse cleanly (json.loads)
        # Catches malformed package.json, biome.json, etc. Skips:
        #   - huge npm/bun lockfiles (machine-generated, slow to
        #     parse, no value).
        #   - tsconfig*.json: TypeScript convention is JSONC (JSON
        #     with `/* ... */` comments), which standard json.loads
        #     rejects. Strip-and-validate would need json5 or a
        #     hand-rolled comment scrubber for marginal value, since
        #     `tsc --noEmit` already validates these in Frontend CI.
        run: |
          python <<'PY'
          import fnmatch, json, pathlib, sys

          SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
                        "node_modules", "unsloth_compiled_cache",
                        "unsloth.egg-info"}
          SKIP_NAMES = {"package-lock.json", "bun.lock"}
          SKIP_PATTERNS = ("tsconfig*.json",)

          bad = []
          scanned = 0
          for path in sorted(pathlib.Path(".").rglob("*.json")):
              if any(part in SKIP_PARTS for part in path.parts):
                  continue
              if path.name in SKIP_NAMES:
                  continue
              if any(fnmatch.fnmatch(path.name, pat) for pat in SKIP_PATTERNS):
                  continue
              scanned += 1
              try:
                  json.loads(path.read_text(encoding="utf-8"))
              except Exception as exc:
                  bad.append((path, exc))

          if bad:
              for path, exc in bad:
                  print(f"::error file={path}::JSON parse failed: {exc}")
              sys.exit(1)
          print(f"{scanned} JSON files parse cleanly")
          PY

      - name: codespell typo check (informational)
        # Catches typos in code, comments, and docs across the repo.
        # Skips lockfiles, generated assets, binary artefacts, and
        # the LICENSE files (US/UK spelling drift in legal text is
        # not ours to second-guess). The ignore-words-list pulls
        # out short identifiers + valid technical terms that
        # codespell's default dictionary would otherwise flag
        # (e.g. `ans` as a math-quiz variable name in
        # tests/utils/aime_eval.py, `parm`/`parms` in PyTorch
        # nn.Module idioms). Non-blocking until the surfaced typos
        # are fixed; drop continue-on-error after the cleanup.
        continue-on-error: true
        run: |
          codespell \
            --skip='*.lock,*.lockb,*.json,*.svg,*.png,*.jpg,*.jpeg,*.gif,*.ico,*.woff*,*.ttf,*.eot,*.zip,*.gz,*.gguf,*.safetensors,*.bin,node_modules,.git,build,dist,unsloth_compiled_cache,unsloth.egg-info,target,studio/frontend/dist,*.pyc,*-licenses.txt,LICENSE*' \
            --ignore-words-list='ans,bu,hel,fo,te,ot,hist,ned,sav,recurser,datas,nin,parm,parms,checkin,nd,fr,inout,donot,uint' \
            --quiet-level=2

      - name: shellcheck on committed *.sh (informational)
        # Goes beyond `bash -n` (which only parses): catches subtle
        # shell bugs like unquoted variable expansions, useless
        # `cat`, command substitutions inside `[[`, etc. The
        # install/setup scripts are critical-path so the signal is
        # worth surfacing. Non-blocking until install.sh's
        # hand-rolled patterns get cleaned up; drop continue-on-error
        # afterwards.
        continue-on-error: true
        run: |
          # Exclude SC1090 ("source not followable") -- legitimate
          # for installer scripts that source files at runtime
          # paths shellcheck cannot resolve statically.
          # SC2034 ("variable assigned but never used") fires on
          # the export-only assignment idiom we use in install.sh.
          shellcheck -e SC1090,SC2034 $(git ls-files '*.sh')

      - name: ruff format drift (informational)
        # The canonical formatter is scripts/run_ruff_format.py
        # = ruff format + scripts/enforce_kwargs_spacing.py, so plain
        # `ruff format --check` reports the kwarg-spacing diff as
        # drift. Surface the count for visibility but keep
        # non-blocking until the custom pipeline is wired in here.
        continue-on-error: true
        run: |
          ruff format --check unsloth unsloth_cli studio tests cli.py unsloth-cli.py

```

## /.github/workflows/mlx-ci.yml

```yml path="/.github/workflows/mlx-ci.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# Focused PR gate for the MLX dispatch surface, running on a real
# Apple Silicon runner.
#
# Runner: macos-14 (M1, 3 vCPU / 7 GB / Apple Silicon standard runner
# -- FREE for public repositories per the GitHub Actions billing
# reference; larger variants like macos-14-large/-xlarge are paid so
# we deliberately avoid those).
#
# Why a single Mac job (no Linux+spoof leg): the dispatch tests are
# 100% spoofed monkeypatches and run identically on any host, so the
# Linux leg was duplicating the matrix tests already covered on Mac
# while missing everything Apple-specific. The Mac job runs the SAME
# spoofed matrix PLUS three things only a real Apple Silicon host
# can prove:
#
#   1. unsloth._IS_MLX flips True on Darwin+arm64 with mlx genuinely
#      installed (no spoof).
#   2. Every PR-A MLX-only unsloth_zoo module (mlx_loader, mlx_trainer,
#      mlx_compile, mlx_utils, mlx_cce, gated_delta_vjp) imports
#      against the real `mlx` + `mlx-lm` + `mlx-vlm` PyPI wheels --
#      each does `import mlx.core as mx` at module top level, so this
#      catches a future change that breaks the real wheels without
#      needing a Mac developer in the loop.
#   3. The hardware-dispatch spoofs do not collide with the real
#      environment (the test fixture installs a MetaPathFinder that
#      blocks `import mlx.core` for "no-mlx" profiles, faithfully
#      simulating a Mac without mlx even when mlx IS installed).
#   4. End-to-end MLX training + inference smoke test:
#      run_real_mlx_smoke.py trains unsloth/gemma-3-270m-it for 7
#      deterministic LoRA steps on a single repeated text row, then
#      verifies the trained model can complete the prompt and that
#      losses + grad norms are finite and well-behaved. This is the
#      only place in CI that exercises a real MLX backward pass +
#      optimizer step + inference call.
#
# Three dispatch test files documented in tests/studio/README.md:
#   - test_hardware_dispatch_matrix.py    parametrized 7-profile matrix
#                                         + 2 dispatch-priority canaries
#   - test_is_mlx_dispatch_gate.py        AST + runtime guard on
#                                         unsloth._IS_MLX
#   - test_mlx_training_worker_behaviors.py  AST contract checks on
#                                            studio/backend/core/training/worker.py
#
# Surfaces a single PR check ("MLX CI on Mac M1 / dispatch").
#
# Security audit footprint: every package this workflow installs is
# already covered by .github/workflows/security-audit.yml -- the deps
# come from studio/backend/requirements/studio.txt and unsloth-zoo's
# pyproject (resolved transitively). The git+ install of unsloth-zoo
# is intentionally skipped by the audit (pip-audit cannot resolve a
# git URL through PyPI metadata; the audit comment in security-audit.yml
# documents this). No new package is introduced solely by MLX CI.

name: MLX CI on Mac M1

on:
  pull_request:
    paths:
      - 'unsloth/__init__.py'
      - 'unsloth/_gpu_init.py'
      - 'studio/backend/utils/hardware/**'
      - 'studio/backend/core/training/worker.py'
      - 'studio/backend/core/inference/mlx_inference.py'
      - 'tests/studio/test_hardware_dispatch_matrix.py'
      - 'tests/studio/test_is_mlx_dispatch_gate.py'
      - 'tests/studio/test_mlx_training_worker_behaviors.py'
      - 'tests/studio/run_real_mlx_smoke.py'
      - 'tests/conftest.py'
      - '.github/workflows/mlx-ci.yml'
  push:
    branches: [main, pip]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  dispatch:
    name: dispatch
    runs-on: macos-14
    # 25 min: dispatch + spoofed matrix + 7-step real LoRA training is
    # under 2 min; GGUF export builds llama.cpp via cmake on Apple
    # Silicon (~5-7 min), so we budget headroom.
    timeout-minutes: 25
    steps:
      # harden-runner audit mode: macOS runners cannot use blocking mode
      # today (eBPF egress enforcement is Linux-only), but audit mode is
      # supported cross-platform and surfaces the egress destinations in
      # the runner log. This produces the data needed to graduate this
      # job to a block-mode allowlist once macOS support lands.
      - name: Harden runner (audit)
        uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450  # v2.19.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      # macOS install ladder, validated locally against a Linux
      # mac-sim venv (platform spoofed + mlx_simulation shim + real
      # datasets/transformers/structlog).
      #
      # 1. studio/backend/requirements/studio.txt brings structlog,
      #    fastapi, etc. The hardware probe imports structlog at
      #    module top level.
      # 2. Same pytest / numpy / httpx stack the rest of the repo CI
      #    uses.
      # 3. torch is explicitly installed: unsloth-zoo's pyproject
      #    deliberately excludes torch on darwin+arm64 (mlx replaces
      #    it for runtime use), but the dispatch tests spoof
      #    torch.cuda / torch.xpu / torch.backends.mps via monkeypatch
      #    and so the test process needs torch importable. We pull
      #    from the PyTorch CPU index so Apple Silicon gets the
      #    explicit cpu+MPS arm64 wheel rather than something the
      #    default PyPI resolver might pick up. The CPU index hosts
      #    macosx_*_arm64 wheels alongside the Linux x86_64 ones.
      # 4. unsloth-zoo from git main (NOT PyPI), WITH deps. PR-A's
      #    MLX support landed after the most recent unsloth-zoo PyPI
      #    release; the wheel still raises NotImplementedError on
      #    Apple Silicon when device_type.get_device_type() runs
      #    unguarded. Studio's own install.sh overlays unsloth-zoo
      #    from git main for the same reason. Pulling deps lets pip
      #    resolve the platform-conditional MLX-only wheels (mlx,
      #    mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's
      #    pyproject) AND the shared deps (datasets, transformers,
      #    sentencepiece, ...) that unsloth's MLX branch loads via
      #    dataprep/raw_text.py.
      # 5. unsloth -e . --no-deps so the editable install does not
      #    fight the unsloth-zoo dep set.
      #
      # All explicit pip installs are version-pinned to a single
      # released version (the latest as of 2026-05-07 within each
      # project's existing constraint range). bump alongside the rest
      # of the security audit when a new release lands.
      - name: Install deps
        run: |
          python -m pip install --upgrade pip
          pip install -r studio/backend/requirements/studio.txt
          pip install \
            'python-multipart==0.0.27' \
            'aiofiles==25.1.0' \
            'sqlalchemy==2.0.49' \
            'cryptography==48.0.0' \
            'pyyaml==6.0.3' \
            'jinja2==3.1.6' \
            'mammoth==1.12.0' \
            'unpdf==1.0.0' \
            'requests==2.33.1' \
            'typer==0.25.1' \
            'numpy==2.4.4' \
            'pytest==9.0.3' \
            'pytest-asyncio==1.3.0' \
            'httpx==0.28.1'
          pip install --index-url https://download.pytorch.org/whl/cpu \
            'torch==2.10.0'
          # github.com occasionally 500s on the git fetch; retry the
          # zoo install so a single upstream blip does not fail CI.
          for attempt in 1 2 3; do
            if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
              break
            fi
            if [ "$attempt" -eq 3 ]; then
              echo "::error::pip install unsloth_zoo failed after 3 attempts"
              exit 1
            fi
            delay=$((5 * attempt))
            echo "::warning::unsloth_zoo install failed (attempt $attempt/3), retrying in ${delay}s..."
            sleep "$delay"
          done
          pip install -e . --no-deps

      # Real Apple Silicon sanity: confirm _IS_MLX activates on real
      # hardware with no platform spoof.
      - name: Verify _IS_MLX flips True on real Apple Silicon
        run: |
          python -c "
          import platform
          assert platform.system() == 'Darwin', platform.system()
          assert platform.machine() == 'arm64', platform.machine()
          import unsloth
          assert unsloth._IS_MLX is True, f'expected _IS_MLX=True on real Apple Silicon, got {unsloth._IS_MLX}'
          print('OK: _IS_MLX activated on real Apple Silicon')
          "

      # Real Apple Silicon sanity: confirm every PR-A MLX-only module
      # loads against real mlx + mlx-lm + mlx-vlm wheels.
      - name: Smoke-import every MLX-only unsloth_zoo module
        run: |
          python -c "
          import importlib
          for name in [
              'unsloth_zoo.mlx_loader',
              'unsloth_zoo.mlx_trainer',
              'unsloth_zoo.mlx_compile',
              'unsloth_zoo.mlx_utils',
              'unsloth_zoo.mlx_cce',
              'unsloth_zoo.gated_delta_vjp',
          ]:
              importlib.import_module(name)
              print('OK:', name)
          from unsloth_zoo.mlx_loader import FastMLXModel
          from unsloth_zoo.mlx_trainer import MLXTrainer, MLXTrainingConfig
          assert hasattr(FastMLXModel, 'from_pretrained')
          print('OK: FastMLXModel + MLXTrainer surface present')
          "

      # Spoofed dispatch matrix. Runs on the real Mac too -- the
      # test fixture installs a MetaPathFinder that blocks
      # `import mlx.core` for "no-mlx" profiles, so the spoofs
      # faithfully simulate every supported hardware combo regardless
      # of whether mlx is installed for real.
      - name: MLX dispatch tests (3 files, 36 tests)
        env:
          PYTHONPATH: ${{ github.workspace }}/studio
          UNSLOTH_COMPILE_DISABLE: '1'
        run: |
          python -m pytest -v --tb=short \
            tests/studio/test_hardware_dispatch_matrix.py \
            tests/studio/test_is_mlx_dispatch_gate.py \
            tests/studio/test_mlx_training_worker_behaviors.py

      # Studio prebuilt llama.cpp install + GGUF inference. Drives the
      # exact path Studio's setup.sh takes on macOS: invokes
      # studio/install_llama_prebuilt.py with --published-repo
      # ggml-org/llama.cpp and --published-release-tag b9049 (the
      # latest llama.cpp release at the time this step was added; bump
      # via UNSLOTH_LLAMA_TAG / DEFAULT_LLAMA_TAG when refreshing).
      # The installer downloads llama-b9049-bin-macos-arm64.tar.gz,
      # which is the universal Apple Silicon (arm64) build -- the
      # same artifact works on M1/M2/M3/M4 because llama.cpp compiles
      # against the ARMv8.2 baseline.
      #
      # The b9049 release also publishes:
      #   - llama-b9049-bin-macos-arm64-kleidiai.tar.gz
      #     KleidiAI dispatches at runtime; on M1 it falls back where
      #     ISA features (e.g. I8MM) are missing, so this asset also
      #     runs on M1 -- Studio just doesn't choose it by default.
      #   - llama-b9049-bin-macos-x64.tar.gz
      #     Intel-only; would only run on M1 via Rosetta 2 emulation,
      #     which we explicitly avoid.
      #   - iOS XCFramework
      #     iOS-app build artifact, unrelated to a macOS desktop CI.
      #
      # After install, downloads a small published GGUF
      # (unsloth/gemma-3-270m-it-GGUF, Q4_K_M) from HuggingFace and
      # runs the prebuilt llama-cli on it. Asserts the prompt echo
      # appears in stdout. If the install fails OR the binary exits
      # non-zero, that's an Unsloth/Studio bug.
      - name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
          # install_llama_prebuilt.py hits the GitHub releases API to
          # resolve the asset URL. Anonymous calls share the runner-IP
          # rate-limit bucket and 403 quickly -- pass the workflow's
          # automatic GITHUB_TOKEN to bump us to the 5000/hr authenticated
          # bucket.
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
          rm -rf "$INSTALL_DIR"
          # --simple-policy is required when --published-repo points
          # at upstream ggml-org/llama.cpp; that repo doesn't ship the
          # llama-prebuilt-manifest.json asset Studio's default policy
          # expects, so the simple platform-specific policy maps
          # Darwin+arm64 -> bin-macos-arm64 directly. studio/setup.sh
          # passes both --published-repo ggml-org/llama.cpp AND
          # --simple-policy automatically on macOS, so this CI step
          # exercises the same code path users hit when they run
          # `curl -fsSL https://unsloth.ai/install.sh | sh`.
          python studio/install_llama_prebuilt.py \
            --install-dir "$INSTALL_DIR" \
            --published-repo ggml-org/llama.cpp \
            --published-release-tag b9049 \
            --simple-policy

          # Studio bundles only llama-server + llama-quantize from the
          # prebuilt (not llama-cli) -- inference goes through
          # llama-server's HTTP /completion endpoint. Validate both:
          # llama-quantize --help proves the dynamic libs link, then
          # spin up llama-server and POST a /completion request on a
          # tiny published GGUF.
          LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
          LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
          [ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
          [ -x "$LLAMA_QUANT" ]  || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; }
          echo "llama-server : $LLAMA_SERVER"
          echo "llama-quantize: $LLAMA_QUANT"
          "$LLAMA_QUANT" --help >/dev/null && echo "  llama-quantize loads OK"

          mkdir -p /tmp/ggufs
          python -c "
          from huggingface_hub import hf_hub_download
          p = hf_hub_download(
              'unsloth/gemma-3-270m-it-GGUF',
              'gemma-3-270m-it-Q4_K_M.gguf',
              local_dir = '/tmp/ggufs',
          )
          print('downloaded:', p)
          "

          PORT=18080
          echo "=== starting llama-server on 127.0.0.1:$PORT ==="
          "$LLAMA_SERVER" \
            -m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \
            --host 127.0.0.1 \
            --port "$PORT" \
            -c 256 \
            -n 16 \
            --no-warmup \
            > /tmp/llama-server.log 2>&1 &
          SERVER_PID=$!
          trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT

          # Wait for /health to come up
          for i in $(seq 1 30); do
            if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
              echo "  server up after ${i}s"
              break
            fi
            sleep 1
          done
          if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
            echo "::error::llama-server never became healthy"
            tail -40 /tmp/llama-server.log
            exit 1
          fi

          PROMPT="Hello, my name is"
          echo "=== POST /completion ==="
          RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \
            -H 'Content-Type: application/json' \
            -d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}")
          echo "raw response (head): $(echo "$RESP" | head -c 600)"
          CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))")
          echo "completion content: $CONTENT"

          if [ -z "$CONTENT" ]; then
            echo "::error::llama-server /completion returned empty content"
            tail -40 /tmp/llama-server.log
            exit 1
          fi
          echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"

      # Real MLX training + inference smoke test. Trains
      # unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
      # (batch_size=2, gradient_accumulation_steps=3) on a single
      # repeated row ("<<HELLO!!>> My name is Unsloth!"), then saves
      # the trained model in 3 export formats. The `train` subcommand
      # captures per-phase timing + peak GPU + peak RSS into
      # train_metrics.json so we can detect regressions across CI runs.
      - name: MLX export round-trip — TRAIN + SAVE 3 formats
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
          UNSLOTH_COMPILE_DISABLE: '1'
        run: |
          mkdir -p mlx_workdir
          python tests/studio/run_real_mlx_smoke.py train \
            --workdir "$PWD/mlx_workdir"

      # Each reload step runs in a FRESH Python process to confirm
      # the cold-start path users would hit in production also works
      # (not just the in-memory continuation of a still-running
      # trainer). FastMLXModel.from_pretrained gets called from
      # scratch; mx.random is re-seeded; per-step timing + peak
      # memory are emitted to {format}_reload_metrics.json next to
      # the saved dir.
      - name: MLX export round-trip — RELOAD LoRA (fresh process)
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
          UNSLOTH_COMPILE_DISABLE: '1'
        run: |
          python tests/studio/run_real_mlx_smoke.py reload \
            --format lora \
            --dir "$PWD/mlx_workdir/lora"

      - name: MLX export round-trip — RELOAD merged_16bit (fresh process)
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
          UNSLOTH_COMPILE_DISABLE: '1'
        run: |
          python tests/studio/run_real_mlx_smoke.py reload \
            --format merged \
            --dir "$PWD/mlx_workdir/merged_16bit"

      # GGUF reload uses the llama-cli binary that save_pretrained_gguf
      # built. If save_pretrained_gguf was skipped during train (e.g.
      # llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer
      # vocab -- a downstream llama.cpp limitation, not an unsloth_zoo
      # bug), this step emits a workflow warning and exits 0 so the
      # LoRA + merged_16bit assertions remain the gating signal.
      - name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
        run: |
          if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
            python tests/studio/run_real_mlx_smoke.py reload \
              --format gguf \
              --dir "$PWD/mlx_workdir/gguf"
          else
            REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')")
            echo "::warning title=GGUF round-trip skipped::${REASON}"
            echo "GGUF export was skipped during the train phase. Reason:"
            echo "  ${REASON}"
            echo "Continuing without failing the job; the LoRA + merged_16bit"
            echo "reload assertions are still gating this PR."
          fi

      # Print all metrics JSON files so regressions are visible in the
      # job log. always() so we get telemetry even if a reload step
      # asserted gibberish.
      - name: MLX export round-trip — aggregate metrics
        if: always()
        run: |
          for f in mlx_workdir/train_metrics.json \
                   mlx_workdir/lora_reload_metrics.json \
                   mlx_workdir/merged_reload_metrics.json \
                   mlx_workdir/gguf_reload_metrics.json; do
            echo "=== $f ==="
            cat "$f" 2>/dev/null || echo "(missing)"
            echo
          done

```

## /.github/workflows/notebooks-ci.yml

```yml path="/.github/workflows/notebooks-ci.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Cross-repo notebook validator. Lives in unslothai/unsloth (this repo)
# and inspects every notebook in unslothai/notebooks at HEAD (or the
# ref dispatched in via repository_dispatch).
#
# Catches the bug classes that landed in:
#   - unslothai/notebooks#258  Colab torchao 0.10 vs peft 0.19 floor
#   - unslothai/notebooks#260  DONT_UPDATE_EXCEPTIONS coverage drift
#   - unslothai/notebooks#261  torch/torchcodec ABI; --no-deps tokenizers
#   - unslothai/notebooks#264  --no-deps transformers + Colab tokenizers drift
#   - unslothai/notebooks#221  git+ HEAD installs in install cells
#   - unslothai/notebooks  commit 51b1462  template/notebook drift
#
# CPU-only by design. Layer 2 (api-introspect) reuses the existing
# tests/_zoo_aggressive_cuda_spoof.py harness so `import unsloth`
# succeeds on a GPU-less ubuntu-latest runner.

name: Notebooks CI

on:
  pull_request:
    paths:
      - 'unsloth/**'
      - 'scripts/notebook_validator.py'
      - 'scripts/notebook_to_python.py'
      - 'scripts/data/colab_pip_freeze.gpu.txt'
      - 'scripts/data/colab_to_cpu_pin.json'
      - 'tests/notebooks/**'
      - 'tests/_zoo_aggressive_cuda_spoof.py'
      - '.github/workflows/notebooks-ci.yml'
  schedule:
    # Daily 06:17 UTC. Catches Colab preinstall bumps (the upstream image
    # is rebuilt roughly weekly) without us waiting on a PR. Off the
    # :00/:30 fleet-collision spots.
    - cron: '17 6 * * *'
  workflow_dispatch:
    inputs:
      notebooks_ref:
        description: 'unslothai/notebooks ref to lint (branch / SHA / tag)'
        default: 'main'
      include_smoke:
        description: 'Also run the install-cell smoke matrix (longer)'
        type: boolean
        default: false
  repository_dispatch:
    # Fired by a tiny companion workflow on unslothai/notebooks.
    types: [notebooks_pr_opened, notebooks_main_pushed]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  NOTEBOOKS_REF: >-
    ${{ github.event.inputs.notebooks_ref ||
        github.event.client_payload.ref ||
        'main' }}

jobs:
  static:
    name: static (drift + lint + exceptions)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      # Validate the dispatched ref before it reaches actions/checkout's `ref:`
      # input. Reading via env (NOT direct ${{ ... }} interpolation in the
      # regex test) closes the GitHub-Actions-injection class where a
      # client_payload.ref like `main"; rm -rf / #` would be embedded into the
      # shell command. NOTEBOOKS_REF defaults to 'main' on non-dispatch
      # events, but only repository_dispatch can supply attacker-controlled
      # values, so we gate this check on that event type.
      - name: Validate client_payload.ref shape
        if: github.event_name == 'repository_dispatch'
        env:
          NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
        run: |
          if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+{{contextString}}#39;; then
            echo "::error::client_payload.ref contains disallowed characters" >&2
            exit 1
          fi

      - name: Checkout unsloth (this PR)
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          path: unsloth
          persist-credentials: false

      - name: Checkout unslothai/notebooks @ ${{ env.NOTEBOOKS_REF }}
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          repository: unslothai/notebooks
          ref: ${{ env.NOTEBOOKS_REF }}
          path: notebooks
          fetch-depth: 0  # drift check needs git status / diff
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Install validator deps
        run: |
          python -m pip install --upgrade pip
          # nbformat + nbconvert come from the converter's requirements;
          # spellchecker + huggingface_hub are imported at module top of
          # update_all_notebooks.py.
          pip install \
            'nbformat>=5.10' 'nbconvert>=7.16' 'pyspellchecker>=0.8' \
            'huggingface_hub>=0.34' 'tqdm>=4.66'

      - name: Refresh Colab pip-freeze (best-effort; falls back to snapshot)
        run: |
          python unsloth/scripts/notebook_validator.py refresh-colab \
              --out unsloth/scripts/data/colab_pip_freeze.gpu.txt \
            || echo "::warning::refresh-colab failed; using committed snapshot"

      - name: Diff Colab oracle vs committed snapshots (advisory)
        # Pulls pip-freeze.gpu.txt + apt-list-gpu.txt + os-info-gpu.txt
        # from googlecolab/backend-info and prints NEW / REMOVED /
        # CHANGED entries against scripts/data/colab_*.txt. Non-blocking
        # on PRs; the daily cron job below runs the same step with
        # --strict so upstream rotations surface within ~24h.
        continue-on-error: true
        working-directory: ${{ github.workspace }}
        run: |
          python unsloth/scripts/notebook_validator.py colab-diff \
              --snapshot-dir unsloth/scripts/data

      - name: Drift check (re-run update_all_notebooks.py + git diff)
        working-directory: ${{ github.workspace }}
        # Reported as non-blocking until the upstream `unslothai/notebooks`
        # tree is regenerated. The first run on @main surfaces ~463 files
        # of drift (7359 / 9634 line delta), which is a real backlog the
        # notebooks-side maintainers need to clear in their own repo --
        # this PR's role is to surface the count, not auto-fix it.
        continue-on-error: true
        run: |
          python unsloth/scripts/notebook_validator.py drift \
              --notebooks-dir notebooks

      - name: Convert sanity (every nb / kaggle / original_template -> .py)
        # Same rationale as Drift: a handful of upstream notebooks fail
        # the converter (custom magics, malformed JSON, etc). Surface
        # the count without blocking; the team triages in unslothai/notebooks.
        continue-on-error: true
        run: |
          python unsloth/scripts/notebook_validator.py convert \
              --notebooks-dir notebooks \
              --out _converted

      - name: Lint (install cells + AST scan, env-scoped)
        # Reported as non-blocking (continue-on-error: true) until the
        # backlog of pre-existing findings on unslothai/notebooks@main is
        # cleared. Same pattern PR #5298 used for biome:check on the
        # frontend. As of this commit the live tree surfaces 27 errors +
        # 6 warnings, all real (peft/torchao floor missing in 6 nb/
        # notebooks, 14 git+ HEAD installs in hand-tuned exception
        # notebooks, 6 torch/torchcodec ABI mismatches, 1
        # transformers/tokenizers --no-deps drift). The count surfaces
        # in the PR check UI. Drop continue-on-error once it hits zero.
        continue-on-error: true
        run: |
          python unsloth/scripts/notebook_validator.py lint \
              --notebooks-dir notebooks \
              --colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt \
              --no-pypi
        # --no-pypi skips R-INST-002 (transitive resolve via PyPI metadata).
        # Layer 1 keeps PR-time wall-clock predictable; the daily cron run
        # below drops --no-pypi and refreshes the cache.

      - name: DONT_UPDATE_EXCEPTIONS coverage
        run: |
          python unsloth/scripts/notebook_validator.py exceptions \
              --notebooks-dir notebooks

  static-with-pypi:
    name: static + transitive resolve (cron / dispatch only)
    if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      # See `static.Validate client_payload.ref shape` for rationale. This
      # job's `if:` excludes repository_dispatch today, so the validation
      # step is a defence-in-depth no-op until that gate ever relaxes.
      - name: Validate client_payload.ref shape
        if: github.event_name == 'repository_dispatch'
        env:
          NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
        run: |
          if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+{{contextString}}#39;; then
            echo "::error::client_payload.ref contains disallowed characters" >&2
            exit 1
          fi
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false
          path: unsloth
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          repository: unslothai/notebooks
          ref: ${{ env.NOTEBOOKS_REF }}
          path: notebooks
          persist-credentials: false
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with: { python-version: '3.12', cache: 'pip' }
      - name: Install
        run: pip install -U pip
      - name: Refresh Colab oracle
        run: |
          python unsloth/scripts/notebook_validator.py refresh-colab \
              --out unsloth/scripts/data/colab_pip_freeze.gpu.txt
      - name: Diff Colab oracle vs committed snapshots (--strict on cron)
        # Cron-only escalation of the advisory PR-time check. Fails if
        # any of pip-freeze.gpu.txt / apt-list-gpu.txt / os-info-gpu.txt
        # has drifted from scripts/data/colab_*.txt; refresh the
        # snapshots in this repo to acknowledge.
        run: |
          python unsloth/scripts/notebook_validator.py colab-diff \
              --snapshot-dir unsloth/scripts/data --strict
      - name: Lint with live PyPI metadata
        run: |
          python unsloth/scripts/notebook_validator.py lint \
              --notebooks-dir notebooks \
              --colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt

  api-introspect:
    name: api surface (under CUDA spoof)
    runs-on: ubuntu-latest
    timeout-minutes: 12
    steps:
      - name: Validate client_payload.ref shape
        if: github.event_name == 'repository_dispatch'
        env:
          NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
        run: |
          if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+{{contextString}}#39;; then
            echo "::error::client_payload.ref contains disallowed characters" >&2
            exit 1
          fi
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false
          path: unsloth
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          repository: unslothai/notebooks
          ref: ${{ env.NOTEBOOKS_REF }}
          path: notebooks
          persist-credentials: false
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with: { python-version: '3.12', cache: 'pip' }

      - name: Install CPU torch + pinned unsloth + trl + converter deps
        run: |
          python -m pip install --upgrade pip
          # CPU torch + torchvision. torchvision is required because
          # unsloth_zoo.vision_utils imports PIL at module top, and the
          # easiest way to get a torch-compatible PIL on a CPU runner is
          # to let torchvision pull the right Pillow version.
          pip install --index-url https://download.pytorch.org/whl/cpu \
                      'torch>=2.8,<2.11' 'torchvision<0.26'
          # Pin to the same versions update_all_notebooks.py installs in
          # generated notebooks. Keep these in lockstep with PIN_TRL /
          # PIN_TRANSFORMERS in unslothai/notebooks/update_all_notebooks.py.
          # `triton` is added because unsloth/_gpu_init.py:232 does an
          # unconditional `import triton`; the PyPI wheel installs cleanly
          # on Linux x86_64 even without CUDA (same rationale as
          # consolidated-tests-ci.yml line 192-205).
          # Pillow is listed explicitly as a defensive belt-and-braces
          # next to torchvision (vision_utils crashes ModuleNotFoundError
          # if torchvision skipped its Pillow dep for any reason).
          pip install 'transformers>=4.56,<5.6' 'trl>=0.22,<0.26' 'accelerate>=1.0' \
                      'datasets>=3.4,<5' 'peft>=0.15,<0.20' \
                      'bitsandbytes>=0.43' 'sentencepiece' 'protobuf' triton \
                      Pillow safetensors tqdm packaging psutil
          # Converter deps (nbformat for notebook_to_python.py).
          pip install 'nbformat>=5.10' 'nbconvert>=7.16'
          # Install unsloth from the LOCAL checkout (the PR head), not PyPI.
          # The PR-time CI must validate the code in this PR; PyPI unsloth
          # may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py
          # (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream.
          pip install --no-deps unsloth_zoo
          pip install --no-deps -e ./unsloth

      - name: Convert notebooks for AST scan
        # Same upstream-conversion-error tolerance as the static job.
        continue-on-error: true
        run: |
          python unsloth/scripts/notebook_validator.py convert \
              --notebooks-dir notebooks --out _converted

      - name: Dump unsloth + trl API surface (under CUDA spoof)
        run: |
          PYTHONPATH=unsloth/tests python -u - <<'PY'
          import sys, json, inspect
          import _zoo_aggressive_cuda_spoof as _spoof
          _spoof.apply()
          import unsloth
          import trl
          surface = {}
          for cls_name in ("FastLanguageModel", "FastVisionModel", "FastModel"):
              cls = getattr(unsloth, cls_name, None)
              if cls is None:
                  continue
              surface[cls_name] = sorted(n for n in dir(cls) if not n.startswith("_"))
          surface["SFTConfig_kwargs"] = sorted(inspect.signature(trl.SFTConfig.__init__).parameters)
          json.dump(surface, open("_api_surface.json", "w"), indent=2)
          print("dumped surface for:", list(surface))
          PY

      - name: Run API rule against converted notebooks
        run: |
          python unsloth/scripts/notebook_validator.py api \
              --converted-dir _converted \
              --surface _api_surface.json

  smoke-install:
    name: smoke install (Colab-shaped venv, opt-in)
    if: ${{ github.event.inputs.include_smoke == 'true' || github.event_name == 'schedule' }}
    runs-on: ubuntu-latest
    timeout-minutes: 25
    strategy:
      fail-fast: false
      matrix:
        # One representative notebook per installation_*_content template.
        # Add rows when a new install template lands in update_all_notebooks.py.
        notebook:
          - 'nb/Llama3.1_(8B)-Alpaca.ipynb'           # installation_content
          - 'nb/Gemma3_(4B)-Vision.ipynb'             # installation_content + vision
          - 'nb/Llama3.1_(8B)-GRPO.ipynb'             # installation_extra_grpo_content
          - 'nb/gpt-oss-(20B)-Fine-tuning.ipynb'      # installation_gpt_oss_content
          - 'nb/Qwen3_5_(4B)_Vision.ipynb'            # installation_qwen3_5_content
          - 'nb/Nemotron-3-Nano-30B-A3B_A100.ipynb'   # installation_nemotron_nano_content
          - 'nb/Whisper.ipynb'                         # installation_whisper_content
          - 'nb/Synthetic_Data_Hackathon.ipynb'        # installation_synthetic_data_content
    steps:
      - name: Validate client_payload.ref shape
        if: github.event_name == 'repository_dispatch'
        env:
          NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
        run: |
          if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+{{contextString}}#39;; then
            echo "::error::client_payload.ref contains disallowed characters" >&2
            exit 1
          fi
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false
          path: unsloth
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          repository: unslothai/notebooks
          ref: ${{ env.NOTEBOOKS_REF }}
          path: notebooks
          persist-credentials: false
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with: { python-version: '3.12' }

      - name: Seed Colab-shaped venv from pip-freeze (CPU-mapped)
        run: |
          # Strip cu128 local versions, route torch/torchvision to the CPU
          # wheel index, drop CUDA-specific deps the runner can't use.
          python -u - <<'PY' > /tmp/seed_pins.txt
          import json, re
          mapping = json.load(open("unsloth/scripts/data/colab_to_cpu_pin.json"))
          rewrite = mapping["rewrite"]
          skip = set(mapping["skip"])
          spoof = set(mapping["module_spoof"])
          out = []
          for line in open("unsloth/scripts/data/colab_pip_freeze.gpu.txt"):
              line = line.strip()
              if not line or line.startswith("#"):
                  continue
              m = re.match(r"^([A-Za-z0-9._-]+)\s*==\s*(.+){{contextString}}quot;, line)
              if not m:
                  continue
              name, ver = m.group(1).lower(), m.group(2)
              if name in skip:
                  continue
              if name in spoof:
                  continue
              if name in rewrite:
                  ver = re.sub(r"[+\-].+{{contextString}}quot;, "", ver)
                  out.append(f"{name}=={ver}")
              else:
                  ver = re.sub(r"[+\-].+{{contextString}}quot;, "", ver)
                  out.append(f"{name}=={ver}")
          print("\n".join(out))
          PY
          head -5 /tmp/seed_pins.txt
          wc -l /tmp/seed_pins.txt

      - name: Install Colab-shaped venv
        run: |
          python -m pip install --upgrade pip
          # Best-effort: any single line that fails to resolve on CPU is
          # tolerated; the smoke contract is "the install cell + the unsloth
          # import works", not "the entire Colab venv reproduces."
          while IFS= read -r spec; do
            pip install "$spec" --index-url https://download.pytorch.org/whl/cpu \
              --extra-index-url https://pypi.org/simple || \
              echo "::warning::pin failed: $spec"
          done < /tmp/seed_pins.txt

      - name: Run install cell
        run: |
          python unsloth/scripts/notebook_validator.py convert \
              --notebooks-dir notebooks --out _converted
          # Take the converted .py and run the install cell only.
          BASE="$(basename '${{ matrix.notebook }}' .ipynb | tr -d '()' | tr -c '[:alnum:]_' _)"
          PY="_converted/${BASE}.py"
          [ -f "$PY" ] || { echo "::error::$PY not found"; ls _converted | head; exit 1; }
          # Truncate at the first `from unsloth import` so we run install +
          # core imports only.
          awk '/^from unsloth import/ { print "import sys; sys.exit(0)"; exit } { print }' "$PY" > _smoke.py
          PYTHONPATH=unsloth/tests python -u - <<'PY'
          import _zoo_aggressive_cuda_spoof as _s; _s.apply()
          # Stub torchcodec for cells that import it — no CPU wheel exists.
          import sys, types
          if "torchcodec" not in sys.modules:
              sys.modules["torchcodec"] = types.ModuleType("torchcodec")
          exec(open("_smoke.py").read(), {"__name__": "__main__"})
          PY

      - name: Verify imports under spoof
        run: |
          PYTHONPATH=unsloth/tests python -u - <<'PY'
          import sys, types
          if "torchcodec" not in sys.modules:
              sys.modules["torchcodec"] = types.ModuleType("torchcodec")
          import _zoo_aggressive_cuda_spoof as _s; _s.apply()
          import unsloth, peft, torch, torchao, transformers, tokenizers
          print("OK: imports pass under CUDA spoof")
          PY

```

## /.github/workflows/release-desktop.yml

```yml path="/.github/workflows/release-desktop.yml" 
name: Release Desktop App

on:
  workflow_dispatch:
    inputs:
      studio_version:
        description: 'Studio version tag to release (for example, v0.1.39-beta)'
        type: string
        required: true
      pypi_version:
        description: 'Exact PyPI unsloth version just published/stamped (for example, 2026.5.3); leave blank to use MIN_DESKTOP_BACKEND_VERSION'
        type: string
        required: false
      draft:
        description: 'Create as draft release; draft runs do not advance desktop-latest updater channel'
        type: boolean
        default: true

permissions:
  contents: read

concurrency:
  group: release-desktop-${{ github.repository }}
  cancel-in-progress: false

jobs:
  prepare-version:
    name: Prepare release versions
    runs-on: ubuntu-latest
    outputs:
      studio_version: ${{ steps.prepare.outputs.studio_version }}
      app_version: ${{ steps.prepare.outputs.app_version }}
      desktop_release_tag: ${{ steps.prepare.outputs.desktop_release_tag }}
      prerelease: ${{ steps.prepare.outputs.prerelease }}
      pypi_version: ${{ steps.prepare.outputs.pypi_version }}

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
        with:
          persist-credentials: false

      - name: Validate release versions
        id: prepare
        shell: bash
        env:
          INPUT_STUDIO_VERSION: ${{ inputs.studio_version }}
          INPUT_PYPI_VERSION: ${{ inputs.pypi_version }}
        run: |
          python3 <<'PY'
          import os
          import pathlib
          import re
          import sys

          studio_version = os.environ['INPUT_STUDIO_VERSION'].strip()
          if not studio_version:
              sys.exit('studio_version is required, for example v0.1.39-beta')
          if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
              sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')

          semver_tag = re.compile(
              r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
              r'(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?{{contextString}}#39;
          )
          if not semver_tag.fullmatch(studio_version):
              sys.exit(f'studio_version must be a SemVer tag with leading v, for example v0.1.39-beta: {studio_version}')

          app_version = studio_version.removeprefix('v')
          desktop_release_tag = f'desktop-v{app_version}'
          prerelease = 'true' if '-' in app_version.split('+', 1)[0] else 'false'

          def parse_backend_version(version):
              match = re.fullmatch(
                  r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
                  r'(?:([a-zA-Z]|\.dev|dev|\.rc|rc|\.post|post)(\d*))?'
                  r'(?:[-+]([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?',
                  version,
              )
              if not match:
                  return None
              major, minor, patch, suffix_name, suffix_number, suffix_text = match.groups()
              if suffix_name:
                  normalized = suffix_name.lower().lstrip('.')
                  order = {'dev': 0, 'a': 1, 'b': 2, 'rc': 3, 'post': 5}.get(normalized)
                  if order is None:
                      return None
                  number = int(suffix_number or '0')
              elif suffix_text:
                  order = 3 if version[version.find(suffix_text) - 1] == '-' else 4
                  number = 0
              else:
                  order = 4
                  number = 0
              return (int(major), int(minor), int(patch), order, number)

          preflight = pathlib.Path('studio/src-tauri/src/preflight/version.rs').read_text()
          match = re.search(r'MIN_DESKTOP_BACKEND_VERSION:\s*&str\s*=\s*"([^"]+)"', preflight)
          if not match:
              sys.exit('Could not read MIN_DESKTOP_BACKEND_VERSION')
          min_backend_version = match.group(1)

          input_pypi_version = os.environ.get('INPUT_PYPI_VERSION', '').strip()
          parsed_min_backend = parse_backend_version(min_backend_version)
          if parsed_min_backend is None:
              sys.exit(f'MIN_DESKTOP_BACKEND_VERSION is not a supported backend package version: {min_backend_version}')

          pypi_version = input_pypi_version or min_backend_version
          parsed_pypi = parse_backend_version(pypi_version)
          if parsed_pypi is None:
              sys.exit(f'pypi_version is not a supported backend package version: {pypi_version}')
          if parsed_pypi < parsed_min_backend:
              sys.exit(
                  f'pypi_version {pypi_version} is lower than desktop minimum '
                  f'MIN_DESKTOP_BACKEND_VERSION {min_backend_version}'
              )

          if input_pypi_version:
              print(
                  'Using exact PyPI unsloth version from pypi_version input: '
                  f'{pypi_version} (desktop minimum: {min_backend_version})'
              )
          else:
              print(
                  'Using exact PyPI unsloth version from MIN_DESKTOP_BACKEND_VERSION: '
                  f'{pypi_version}'
              )

          with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
              print(f'studio_version={studio_version}', file=output)
              print(f'app_version={app_version}', file=output)
              print(f'desktop_release_tag={desktop_release_tag}', file=output)
              print(f'prerelease={prerelease}', file=output)
              print(f'pypi_version={pypi_version}', file=output)
          PY

      - name: Verify PyPI package and Studio stamp
        shell: bash
        env:
          STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
          PYPI_VERSION: ${{ steps.prepare.outputs.pypi_version }}
        run: |
          set -euo pipefail
          python3 <<'PY'
          import json
          import os
          import pathlib
          import sys
          import time
          import urllib.error
          import urllib.request

          pypi_version = os.environ['PYPI_VERSION']
          dist_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'pypi-unsloth-dist')
          dist_dir.mkdir(parents=True, exist_ok=True)
          metadata_url = f'https://pypi.org/pypi/unsloth/{pypi_version}/json'

          last_error = None
          for attempt in range(1, 6):
              try:
                  with urllib.request.urlopen(metadata_url, timeout=30) as response:
                      metadata = json.load(response)
                  break
              except Exception as exc:
                  last_error = exc
                  if attempt < 5:
                      time.sleep(10 * attempt)
          else:
              sys.exit(f'Publish unsloth=={pypi_version} to PyPI before the desktop release ({last_error})')

          files = metadata.get('urls') or []
          if not files:
              sys.exit(f'PyPI returned no distribution files for unsloth=={pypi_version}')

          for file_info in files:
              filename = file_info.get('filename')
              url = file_info.get('url')
              if not filename or '/' in filename or not url:
                  sys.exit(f'Unexpected PyPI file entry for unsloth=={pypi_version}: {file_info!r}')
              target = dist_dir / filename
              for attempt in range(1, 4):
                  try:
                      with urllib.request.urlopen(url, timeout=60) as response:
                          target.write_bytes(response.read())
                      break
                  except Exception as exc:
                      last_error = exc
                      if attempt < 3:
                          time.sleep(5 * attempt)
              else:
                  sys.exit(f'Could not download {filename} from PyPI ({last_error})')
          PY

          if [ -f scripts/stamp_studio_release.py ]; then
            mapfile -t dists < <(find "$RUNNER_TEMP/pypi-unsloth-dist" -type f \( -name '*.whl' -o -name '*.tar.gz' \) | sort)
            if [ "${#dists[@]}" -eq 0 ]; then
              echo "No PyPI wheel/sdist artifacts downloaded for unsloth==$PYPI_VERSION" >&2
              exit 1
            fi
            python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
          else
            echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
            exit 1
          fi

      - name: Guard public updater channel version
        if: ${{ !inputs.draft }}
        shell: bash
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ github.token }}
          APP_VERSION: ${{ steps.prepare.outputs.app_version }}
        run: |
          set -euo pipefail
          mkdir -p "$RUNNER_TEMP/desktop-current"
          if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
            echo "No existing desktop-latest latest.json found; allowing first channel publish."
            exit 0
          fi
          python3 <<'PY'
          import json
          import os
          import pathlib
          import re
          import sys

          def parse(value: str):
              value = value.removeprefix('v')
              match = re.fullmatch(
                  r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
                  r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
                  r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
                  value,
              )
              if not match:
                  sys.exit(f'desktop-latest latest.json has invalid version: {value}')
              major, minor, patch, prerelease = match.groups()
              return (int(major), int(minor), int(patch), prerelease)

          def numeric_tail(identifier: str) -> tuple[str, int] | None:
              match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
              if not match:
                  return None
              return (match.group(1).lower(), int(match.group(2)))

          def compare_identifier(left: str, right: str) -> int:
              left_num = left.isdigit()
              right_num = right.isdigit()
              if left_num and right_num:
                  return (int(left) > int(right)) - (int(left) < int(right))
              if left_num:
                  return -1
              if right_num:
                  return 1

              left_tail = numeric_tail(left)
              right_tail = numeric_tail(right)
              if left_tail and right_tail and left_tail[0] == right_tail[0]:
                  return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])

              return (left > right) - (left < right)

          def compare_prerelease(left: str | None, right: str | None) -> int:
              if left == right:
                  return 0
              if left is None:
                  return 1
              if right is None:
                  return -1
              left_parts = left.split('.')
              right_parts = right.split('.')
              for left_part, right_part in zip(left_parts, right_parts):
                  order = compare_identifier(left_part, right_part)
                  if order:
                      return order
              return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))

          def compare(left: str, right: str) -> int:
              left_major, left_minor, left_patch, left_pre = parse(left)
              right_major, right_minor, right_patch, right_pre = parse(right)
              left_core = (left_major, left_minor, left_patch)
              right_core = (right_major, right_minor, right_patch)
              if left_core != right_core:
                  return (left_core > right_core) - (left_core < right_core)
              return compare_prerelease(left_pre, right_pre)

          current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
          current = json.loads(current_path.read_text()).get('version')
          next_version = os.environ['APP_VERSION']
          if not isinstance(current, str):
              sys.exit('desktop-latest latest.json has missing version')
          if compare(next_version, current) < 0:
              sys.exit(
                  f'Refusing to publish {next_version}; desktop-latest currently points at newer version {current}.'
              )
          PY

  build:
    # TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
    # with actions/upload-artifact handoff so the matrix build cannot
    # publish a Release on its own. The current matrix runs across
    # Linux/macOS/Windows in a single job, so the split needs artefact
    # collection across the OS matrix and is out of scope for this
    # hardening pass.
    permissions:
      contents: write  # tauri-apps/tauri-action creates / uploads a GitHub Release
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix:
        include:
          - platform: macos-latest
            args: '--target aarch64-apple-darwin'
            label: macOS (Apple Silicon)
          # - platform: macos-latest
          #   args: '--target x86_64-apple-darwin'
          #   label: macOS (Intel)
          - platform: ubuntu-22.04
            args: ''
            label: Linux (x64)
          - platform: windows-latest
            args: ''
            label: Windows (x64)

    name: Build ${{ matrix.label }}
    needs: prepare-version
    runs-on: ${{ matrix.platform }}

    env:
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
      APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
      STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
      DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
      DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}

    steps:
      # harden-runner in audit mode: surfaces every egress destination in
      # the runner log so the allowlist for a future `egress-policy: block`
      # promotion can be derived from observed traffic. Audit mode is
      # cross-platform (Linux / macOS / Windows runners); blocking mode is
      # currently Linux-only, so we deliberately stay in audit until the
      # macOS + Windows codesign paths have been observed.
      - name: Harden runner (audit)
        uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450  # v2.19.1
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
        with:
          persist-credentials: false

      # ── Linux dependencies ──
      - name: Install Linux dependencies
        if: matrix.platform == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf

      # ── Node.js ──
      - name: Setup Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
        with:
          node-version: 24

      - name: Install pinned Tauri CLI
        # Lifecycle scripts (esbuild native-binary postinstall, etc.) are
        # required for `vite build`. The pre-install lockfile structural
        # audit (lockfile_supply_chain_audit.py) is the practical defence
        # against the npm postinstall-dropper class -- it fires BEFORE any
        # tarball runs, on the injection pattern itself rather than an
        # advisory-DB lookup.
        run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit

      - name: Verify pinned Tauri CLI
        shell: bash
        run: |
          out="$(npx --prefix studio tauri --version)"
          echo "$out"
          if [ "$out" != "tauri-cli 2.10.1" ]; then
            echo "Expected tauri-cli 2.10.1, got $out" >&2
            exit 1
          fi

      - name: Verify desktop updater and Linux package config
        shell: bash
        run: |
          node <<'JS'
          const { readFileSync } = require('node:fs');

          const expected = 'https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json';
          const config = JSON.parse(readFileSync('studio/src-tauri/tauri.conf.json', 'utf8'));
          const endpoints = config.plugins?.updater?.endpoints;
          if (!Array.isArray(endpoints) || endpoints.length !== 1) {
            throw new Error('Expected exactly one desktop updater endpoint');
          }
          if (endpoints[0] !== expected) {
            throw new Error('Desktop updater endpoint must be ' + expected + ', got ' + endpoints[0]);
          }
          if (endpoints.some((endpoint) => endpoint.includes('/releases/latest/'))) {
            throw new Error('Desktop updater endpoint must not use repo-wide /releases/latest/');
          }

          const targets = config.bundle?.targets;
          if (Array.isArray(targets) && targets.some((target) => String(target).toLowerCase() === 'rpm')) {
            throw new Error('Desktop release must not target RPM packages');
          }
          if (config.bundle?.linux?.rpm) {
            throw new Error('bundle.linux.rpm must not be configured');
          }

          const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
          const lines = workflow.split(/\r?\n/);
          const releaseBodies = [];
          for (let i = 0; i < lines.length; i += 1) {
            const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
            if (!match) continue;
            const baseIndent = match[1].length;
            const bodyLines = [];
            i += 1;
            for (; i < lines.length; i += 1) {
              const line = lines[i];
              if (line.trim() === '') {
                bodyLines.push('');
                continue;
              }
              const indent = line.match(/^\s*/)[0].length;
              if (indent <= baseIndent) {
                i -= 1;
                break;
              }
              bodyLines.push(line.slice(baseIndent + 2));
            }
            releaseBodies.push(bodyLines.join('\n'));
          }
          if (releaseBodies.length === 0) {
            throw new Error('Expected at least one desktop release body');
          }
          for (const body of releaseBodies) {
            if (/\brpm\b|\.rpm/i.test(body)) {
              throw new Error('Desktop release body must not advertise RPM packages');
            }
          }
          JS

      - name: Install frontend dependencies
        working-directory: studio/frontend
        # Lifecycle scripts (esbuild native-binary postinstall, etc.) are
        # required for `vite build`. The pre-install lockfile structural
        # audit (lockfile_supply_chain_audit.py) is the practical defence
        # against the npm postinstall-dropper class -- it fires BEFORE any
        # tarball runs, on the injection pattern itself rather than an
        # advisory-DB lookup.
        run: npm install --no-fund --no-audit

      # ── Rust ──
      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8  # stable @ 2026-03-27
        with:
          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}

      - name: Patch desktop app version
        shell: bash
        working-directory: studio/src-tauri
        run: |
          set -euo pipefail
          if command -v python3 >/dev/null 2>&1; then
            PYTHON=python3
          else
            PYTHON=python
          fi
          "$PYTHON" <<'PY'
          import os
          import pathlib
          import re
          import sys

          app_version = os.environ['APP_VERSION']
          if not app_version:
              sys.exit('APP_VERSION is required')

          cargo_toml = pathlib.Path('Cargo.toml')
          lines = cargo_toml.read_text().splitlines(keepends=True)
          in_package = False
          patched = False
          for index, line in enumerate(lines):
              stripped = line.strip()
              if stripped == '[package]':
                  in_package = True
                  continue
              if stripped.startswith('[') and stripped.endswith(']'):
                  in_package = False
              if in_package and re.fullmatch(r'version\s*=\s*"[^"]+"\s*', stripped):
                  lines[index] = f'version = "{app_version}"\n'
                  patched = True
                  break
          if not patched:
              sys.exit('Could not patch [package] version in Cargo.toml')
          cargo_toml.write_text(''.join(lines))

          cargo_lock = pathlib.Path('Cargo.lock')
          lock_text = cargo_lock.read_text()
          lock_text, count = re.subn(
              r'(?m)(^\[\[package\]\]\nname = "unsloth-studio"\nversion = ")[^"]+(")',
              lambda match: f'{match.group(1)}{app_version}{match.group(2)}',
              lock_text,
          )
          if count != 1:
              sys.exit(f'Could not patch unsloth-studio version in Cargo.lock (matches={count})')
          cargo_lock.write_text(lock_text)
          PY

          cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cargo-metadata.json"
          "$PYTHON" <<'PY'
          import json
          import os
          import pathlib
          import sys

          app_version = os.environ['APP_VERSION']
          metadata = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'cargo-metadata.json').read_text())
          versions = [package['version'] for package in metadata.get('packages', []) if package.get('name') == 'unsloth-studio']
          if versions != [app_version]:
              sys.exit(f'cargo metadata unsloth-studio version mismatch: expected {app_version}, got {versions}')
          PY

          git diff -- Cargo.toml Cargo.lock

      - name: Rust cache
        uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
        with:
          workspaces: 'studio/src-tauri -> target'

      # ── macOS: import signing certificate ──
      - name: Import Apple certificate
        if: matrix.platform == 'macos-latest'
        env:
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
          security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security set-keychain-settings -t 3600 -u build.keychain
          security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
          security find-identity -v -p codesigning build.keychain
          rm -f certificate.p12

      # ── Windows: install Azure Trusted Signing CLI ──
      - name: Install trusted-signing-cli
        if: matrix.platform == 'windows-latest'
        run: |
          cargo install trusted-signing-cli --version 0.9.0 --locked
          echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

      # ── Windows: verify signing CLI is accessible ──
      - name: Verify trusted-signing-cli
        if: matrix.platform == 'windows-latest'
        run: |
          Write-Output "PATH: $env:PATH"
          Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
          trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"

      # ── Linux: build + sign + upload ──
      - name: Build Linux app
        if: matrix.platform == 'ubuntu-22.04'
        uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
        with:
          projectPath: studio
          tauriScript: npx --prefix . tauri
          tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
          releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
          releaseBody: |
            Desktop app for Unsloth Studio.

            **macOS**: Download the Apple Silicon `.dmg`.
            **Windows**: Download the `-setup.exe` installer.
            **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).

            > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
            > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
            > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
          releaseDraft: ${{ inputs.draft }}
          prerelease: ${{ needs.prepare-version.outputs.prerelease }}
          args: -v ${{ matrix.args }}

      # ── macOS: build + sign + notarize + upload ──
      - name: Build macOS app
        if: matrix.platform == 'macos-latest'
        uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
        with:
          projectPath: studio
          tauriScript: npx --prefix . tauri
          tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
          releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
          releaseBody: |
            Desktop app for Unsloth Studio.

            **macOS**: Download the Apple Silicon `.dmg`.
            **Windows**: Download the `-setup.exe` installer.
            **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).

            > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
            > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
            > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
          releaseDraft: ${{ inputs.draft }}
          prerelease: ${{ needs.prepare-version.outputs.prerelease }}
          args: -v ${{ matrix.args }}

      # ── Windows: build + sign + upload ──
      - name: Build Windows app
        if: matrix.platform == 'windows-latest'
        uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
          AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
          AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
          AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
          AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
        with:
          projectPath: studio
          tauriScript: npx --prefix . tauri
          tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
          releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
          releaseBody: |
            Desktop app for Unsloth Studio.

            **macOS**: Download the Apple Silicon `.dmg`.
            **Windows**: Download the `-setup.exe` installer.
            **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).

            > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
            > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
            > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
          releaseDraft: ${{ inputs.draft }}
          prerelease: ${{ needs.prepare-version.outputs.prerelease }}
          args: -v ${{ matrix.args }}

  # Release process note: only non-draft workflow runs advance the public
  # desktop-latest updater channel. Draft builds are for private review; if a
  # draft is manually published later, this channel intentionally remains
  # unchanged until a narrow manual channel-publish flow is added or a public
  # desktop release is created by running this workflow with draft=false.
  publish-updater-channel:
    name: Publish desktop updater channel
    needs: [prepare-version, build]
    if: ${{ !inputs.draft }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    env:
      GH_REPO: ${{ github.repository }}
      APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
      STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
      DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
      DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}

    steps:
      - name: Download versioned updater metadata
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          mkdir -p "$RUNNER_TEMP/desktop-updater"
          gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/source-release.json"
          python3 <<'PY'
          import json
          import os
          import pathlib
          import sys

          source = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'source-release.json').read_text())
          expected_tag = os.environ['DESKTOP_RELEASE_TAG']
          if source.get('tag_name') != expected_tag:
              sys.exit(f'Expected source release {expected_tag}, got {source.get("tag_name")}')
          if source.get('draft'):
              sys.exit(f'Source desktop release {expected_tag} is draft; refusing to publish public updater channel')
          PY
          gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json --dir "$RUNNER_TEMP/desktop-updater" --clobber
          test -s "$RUNNER_TEMP/desktop-updater/latest.json"

      - name: Validate versioned updater metadata
        shell: bash
        run: |
          python3 <<'PY'
          import json
          import os
          import pathlib
          import re
          import sys

          app_version = os.environ['APP_VERSION']
          release_tag = os.environ['DESKTOP_RELEASE_TAG']
          latest_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
          data = json.loads(latest_path.read_text())
          if not isinstance(data, dict):
              sys.exit('latest.json must be a JSON object')

          version = data.get('version')
          if not isinstance(version, str) or not version:
              sys.exit('latest.json missing version')
          if not re.fullmatch(r'v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version):
              sys.exit(f'latest.json version is not SemVer-like: {version}')
          if version.removeprefix('v') != app_version:
              sys.exit(f'latest.json version {version} does not match desktop app version {app_version}')

          platforms = data.get('platforms')
          if not isinstance(platforms, dict) or not platforms:
              sys.exit('latest.json missing platforms')

          required_families = {
              'darwin-aarch64': False,
              'linux-x86_64': False,
              'windows-x86_64': False,
          }
          expected_prefix = f'https://github.com/unslothai/unsloth/releases/download/{release_tag}/'
          forbidden_fragments = ('/releases/latest/', '/releases/download/desktop-latest/')

          for platform, entry in platforms.items():
              if not isinstance(entry, dict):
                  sys.exit(f'Platform {platform} must be an object')
              url = entry.get('url')
              signature = entry.get('signature')
              if not isinstance(url, str) or not url.strip():
                  sys.exit(f'Platform {platform} missing url')
              if not isinstance(signature, str) or not signature.strip():
                  sys.exit(f'Platform {platform} missing signature')
              if any(fragment in url for fragment in forbidden_fragments):
                  sys.exit(f'Platform {platform} points at a moving updater channel: {url}')
              if not url.startswith(expected_prefix):
                  sys.exit(f'Platform {platform} URL must point at {release_tag}: {url}')
              for family in required_families:
                  if platform == family or platform.startswith(family + '-'):
                      required_families[family] = True

          missing = [family for family, found in required_families.items() if not found]
          if missing:
              sys.exit('latest.json missing required platform families: ' + ', '.join(missing))
          PY

      - name: Ensure desktop updater channel release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          channel_json="$RUNNER_TEMP/desktop-latest-release.json"
          if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json" 2>/dev/null; then
            gh release create desktop-latest \
              --title "Unsloth Studio Desktop updater channel" \
              --notes "Machine-managed desktop updater channel; latest.json is replaced by release-desktop.yml." \
              --prerelease \
              --latest=false \
              --target "$GITHUB_SHA"
            gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json"
          fi

          python3 <<'PY'
          import json
          import os
          import pathlib
          import sys

          channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
          if channel.get('draft'):
              sys.exit('desktop-latest release is draft; refusing to publish updater channel')
          if channel.get('immutable'):
              sys.exit('desktop-latest release is immutable; cannot replace latest.json')
          if not channel.get('prerelease'):
              sys.exit('desktop-latest release must be a prerelease so it cannot compete with repo-wide latest')
          PY

      - name: Prevent updater channel downgrade
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          mkdir -p "$RUNNER_TEMP/desktop-current"
          if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
            echo "No existing desktop-latest latest.json found; allowing first channel publish."
            exit 0
          fi
          python3 <<'PY'
          import json
          import os
          import pathlib
          import re
          import sys

          def parse(value: str):
              value = value.removeprefix('v')
              match = re.fullmatch(
                  r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
                  r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
                  r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
                  value,
              )
              if not match:
                  sys.exit(f'desktop-latest latest.json has invalid version: {value}')
              major, minor, patch, prerelease = match.groups()
              return (int(major), int(minor), int(patch), prerelease)

          def numeric_tail(identifier: str) -> tuple[str, int] | None:
              match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
              if not match:
                  return None
              return (match.group(1).lower(), int(match.group(2)))

          def compare_identifier(left: str, right: str) -> int:
              left_num = left.isdigit()
              right_num = right.isdigit()
              if left_num and right_num:
                  return (int(left) > int(right)) - (int(left) < int(right))
              if left_num:
                  return -1
              if right_num:
                  return 1

              left_tail = numeric_tail(left)
              right_tail = numeric_tail(right)
              if left_tail and right_tail and left_tail[0] == right_tail[0]:
                  return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])

              return (left > right) - (left < right)

          def compare_prerelease(left: str | None, right: str | None) -> int:
              if left == right:
                  return 0
              if left is None:
                  return 1
              if right is None:
                  return -1
              left_parts = left.split('.')
              right_parts = right.split('.')
              for left_part, right_part in zip(left_parts, right_parts):
                  order = compare_identifier(left_part, right_part)
                  if order:
                      return order
              return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))

          def compare(left: str, right: str) -> int:
              left_major, left_minor, left_patch, left_pre = parse(left)
              right_major, right_minor, right_patch, right_pre = parse(right)
              left_core = (left_major, left_minor, left_patch)
              right_core = (right_major, right_minor, right_patch)
              if left_core != right_core:
                  return (left_core > right_core) - (left_core < right_core)
              return compare_prerelease(left_pre, right_pre)

          current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
          next_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
          current = json.loads(current_path.read_text()).get('version')
          next_version = json.loads(next_path.read_text()).get('version')
          if not isinstance(current, str) or not isinstance(next_version, str):
              sys.exit('Could not compare desktop-latest channel versions')
          if compare(next_version, current) < 0:
              sys.exit(
                  f'Refusing to move desktop-latest from {current} to older version {next_version}.'
              )
          PY

      - name: Publish desktop updater channel metadata
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          gh release upload desktop-latest "$RUNNER_TEMP/desktop-updater/latest.json" --clobber
          gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$RUNNER_TEMP/desktop-latest-release.json"
          python3 <<'PY'
          import json
          import os
          import pathlib
          import sys

          channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
          assets = [asset for asset in channel.get('assets', []) if asset.get('name') == 'latest.json']
          if len(assets) != 1:
              sys.exit(f'Expected exactly one desktop-latest latest.json asset, found {len(assets)}')
          expected_url = f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/desktop-latest/latest.json'
          actual_url = assets[0].get('browser_download_url')
          if actual_url != expected_url:
              sys.exit(f'desktop-latest latest.json URL mismatch: expected {expected_url}, got {actual_url}')
          PY

```

## /.github/workflows/stale.yml

```yml path="/.github/workflows/stale.yml" 
name: 'Inactive Issue Pinger'

on:
  schedule:
    - cron: '30 5 * * *' # Runs at 5:30 UTC every day

jobs:
  stale:
    runs-on: ubuntu-latest
    permissions:
      issues: write

    steps:
      - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f  # v10.2.0
        with:
          # The message to post on stale issues.
          # This message will ping the issue author.
          # Note: The stale bot action does not currently support a direct placeholder for the last commenter.
          # As a workaround, this message encourages any participant to reply.
          stale-issue-message: >
            Is this issue still important to you?
            Apologies in advance we might have missed this issue as well.
            For faster response times, please post on our Reddit server - https://www.reddit.com/r/unsloth or our Discord - https://discord.com/invite/unsloth 

          # The number of days of inactivity before an issue is considered stale.
          days-before-issue-stale: 9999

          # Set to -1 to never close stale issues.
          days-before-issue-close: -1

          # A label to apply to stale issues.
          stale-issue-label: 'inactive'

          # The number of operations to perform per run to avoid rate limiting.
          operations-per-run: 500

          enable-statistics: false

```

## /.github/workflows/studio-api-smoke.yml

```yml path="/.github/workflows/studio-api-smoke.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# Studio API & Auth Tests -- HTTP-level integration tests for the
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
# runs ~30 s and asserts:
#   - CORS hardening (no wildcard + credentials, no bootstrap leak)
#   - /api/system + /api/system/hardware require auth
#   - Auth state machine + JWT expiry
#   - API key lifecycle E2E (create / list / use / delete / reject)
#   - Auth file-mode hardening (Linux only)
#   - Inference lifecycle (force reload, bogus variant, /v1/models, /v1/embeddings, /v1/responses)
#   - Endpoint-by-endpoint auth audit
#
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model
# download is one cache-hit on the second job.

name: Studio API CI

on:
  pull_request:
    paths:
      - 'studio/**'
      - 'unsloth/**'
      - 'unsloth_cli/**'
      - 'install.sh'
      - 'pyproject.toml'
      - 'tests/studio/**'
      - '.github/workflows/studio-api-smoke.yml'
  push:
    branches: [main, pip]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  api-smoke:
    name: Studio API & Auth Tests
    runs-on: ubuntu-latest
    timeout-minutes: 12
    env:
      GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
      GGUF_VARIANT: UD-Q4_K_XL
      GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
      STUDIO_PORT: '18893'
      HF_HOME: ${{ github.workspace }}/hf-cache
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - name: Linux deps
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends \
            libcurl4-openssl-dev libssl-dev jq

      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e  # v6.4.0
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: studio/frontend/package-lock.json

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Restore HF_HOME for ${{ env.GGUF_REPO }}
        id: cache-hf
        uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae  # v5.0.5
        continue-on-error: true
        with:
          path: hf-cache
          # Same key as studio-ui-smoke.yml so the two jobs share a
          # single GGUF download across CI.
          key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1

      - name: Prime HF_HOME with the GGUF
        id: prime-hf
        if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
        run: |
          python -m pip install --upgrade huggingface_hub hf_transfer
          mkdir -p hf-cache
          HF_HUB_ENABLE_HF_TRANSFER=1 \
            hf download "$GGUF_REPO" "$GGUF_FILE"

      - name: Save HF_HOME for ${{ env.GGUF_REPO }}
        if: always() && steps.prime-hf.outcome == 'success'
        uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae  # v5.0.5
        with:
          path: hf-cache
          key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1

      - name: Install Studio (--local, --no-torch)
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          mkdir -p logs
          set -o pipefail
          bash install.sh --local --no-torch 2>&1 | tee logs/install.log

      - name: Install pyjwt for the JWT-expiry forge test
        run: pip install 'pyjwt>=2.6'

      - name: Reset auth + boot Studio (API-only)
        run: |
          unsloth studio reset-password
          mkdir -p logs
          UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
            > logs/studio.log 2>&1 &
          echo "STUDIO_PID=$!" >> "$GITHUB_ENV"

      - name: Wait for /api/health
        run: |
          for i in $(seq 1 180); do
            if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
              jq -e '.status == "healthy"' /tmp/health.json && break
            fi
            sleep 1
          done
          jq -e '.status == "healthy"' /tmp/health.json

      - name: Pass bootstrap password + rotated targets to the test
        # The test does its own bootstrap-login + rotation to exercise
        # the auth state machine; we just pre-mint two random rotated
        # passwords for it. Mask them so the log is clean.
        run: |
          OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
          NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
          NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
          echo "::add-mask::$OLD"
          echo "::add-mask::$NEW"
          echo "::add-mask::$NEW2"
          echo "STUDIO_OLD_PW=$OLD"  >> "$GITHUB_ENV"
          echo "STUDIO_NEW_PW=$NEW"  >> "$GITHUB_ENV"
          echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"

      - name: Run Studio API & Auth tests
        # The script is named WITHOUT a `test_` prefix so it isn't
        # auto-collected by pytest in Backend CI's `tests/` walk
        # (which doesn't set BASE_URL and would crash at import).
        env:
          BASE_URL: http://127.0.0.1:18893
          STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
        run: python tests/studio/studio_api_smoke.py

      - name: Stop Studio
        if: always()
        run: |
          kill "${STUDIO_PID}" 2>/dev/null || true
          sleep 2

      - name: Upload API smoke logs
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7.0.1
        with:
          name: studio-api-smoke-log
          path: |
            logs/install.log
            logs/studio.log
          retention-days: 7

```

## /.github/workflows/studio-backend-ci.yml

```yml path="/.github/workflows/studio-backend-ci.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# Runs the existing studio/backend/tests/ suite (~860 tests, all CPU-friendly)
# on every PR that touches the backend or unsloth library. Until this lands,
# none of those tests run automatically. Verified locally on Python 3.13 with
# the surgical exclusions below: 861 pass, 4 skipped.
#
# Exclusions:
#   - tests/test_studio_api.py: end-to-end against a live model + GGUF download,
#     too heavy for free runners. Run separately when GPU CI is available.
#   - -k 'not llama_cpp_load_progress_live': spawns a real llama.cpp process,
#     not appropriate for CPU-only runners.
#
# Two jobs:
#   - pytest matrix (3.10/3.11/3.12/3.13) over studio/backend/tests
#   - repo-cpu-tests: auto-discovered tests/ + state-isolated spoof files
#
# Whole-repo Python lint (syntax + ruff + debugger-leftover scan)
# moved to the dedicated `Lint CI` workflow (.github/workflows/lint-ci.yml)
# so it fires on every PR rather than only on studio/unsloth/tests
# path changes.

name: Backend CI

on:
  pull_request:
    paths:
      - 'studio/**'
      - 'unsloth/**'
      - 'unsloth_cli/**'
      - 'tests/**'
      - 'pyproject.toml'
      - '.github/workflows/studio-backend-ci.yml'
  push:
    branches: [main, pip]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  pytest:
    name: (Python ${{ matrix.python }})
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        python: ['3.10', '3.11', '3.12', '3.13']
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '${{ matrix.python }}'
          cache: 'pip'

      - name: Install backend test dependencies (CPU only)
        run: |
          python -m pip install --upgrade pip
          # Studio's declared backend deps:
          pip install -r studio/backend/requirements/studio.txt
          # Extras that studio.txt does not list but the import chain needs
          # (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
          #  for the auth DB, yaml/jinja2 for utils.models.model_config, etc.):
          pip install \
            python-multipart aiofiles sqlalchemy cryptography \
            pyyaml jinja2 mammoth unpdf requests \
            'numpy<3' pytest pytest-asyncio httpx
          # Torch CPU + transformers are required by a chunk of the backend test
          # suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch
          # keeps the install ~250 MB / ~1 min on a clean runner.
          pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11'
          pip install 'transformers>=4.51,<5.5'

      - name: Backend tests
        working-directory: studio/backend
        # Locally validated against this dep set: 831 passed, 5 skipped, 35 deselected.
        # Deselections (all environment-specific, would never pass on a GPU-less
        # `ubuntu-latest` runner regardless of code correctness):
        #   - llama_cpp_load_progress_live: spawns a real llama.cpp process
        #   - TestGpuAutoSelection / TestPreSpawnGpuResolution / TestPerGpuFitGuardAllCounts:
        #       require live transformers config introspection on real GPUs
        #   - TestTransformersIntrospection: same
        #   - test_returns_cuda_when_cuda_available / test_calls_cuda_cache_when_cuda:
        #       assume CUDA-capable GPU
        run: |
          python -m pytest tests/ -q --tb=short \
            --ignore=tests/test_studio_api.py \
            -k 'not llama_cpp_load_progress_live and not TestGpuAutoSelection and not TestPreSpawnGpuResolution and not TestPerGpuFitGuardAllCounts and not TestTransformersIntrospection and not test_returns_cuda_when_cuda_available and not test_calls_cuda_cache_when_cuda'

  repo-cpu-tests:
    # Auto-discover everything under tests/ that is not GPU-bound by
    # design. New tests added in covered directories are picked up
    # without a workflow edit. Locally validated: 760 passed, 1 skipped,
    # 23 deselected. tests/conftest.py (mirroring unsloth-zoo PR #624)
    # pre-loads unsloth_zoo.device_type and unsloth.device_type under a
    # mocked torch.cuda.is_available so the unsloth import chain
    # succeeds on CPU.
    name: Repo tests (CPU)
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      # node + uv unlock ~60 tests that previously skipped on CI:
      #   - 9 tests in test_chat_preset_builtin_invariants.py need node to
      #     compile a tiny TS harness against the frontend chat sources.
      #   - tests/python/* spawn fresh `uv venv`s to verify the no-torch
      #     install path; they self-skip when uv is missing.
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e  # v6.4.0
        with:
          node-version: '22'

      - name: Install uv (for tests/python/* sandboxed venvs)
        run: pip install uv

      - name: Install deps (shared shape with backend pytest job)
        run: |
          python -m pip install --upgrade pip
          pip install -r studio/backend/requirements/studio.txt
          pip install \
            python-multipart aiofiles sqlalchemy cryptography \
            pyyaml jinja2 mammoth unpdf requests typer \
            'numpy<3' pytest pytest-asyncio httpx
          # torchvision: unsloth_zoo.vision_utils imports it at module scope.
          pip install --index-url https://download.pytorch.org/whl/cpu \
            'torch>=2.4,<2.11' 'torchvision<0.26'
          pip install 'transformers>=4.51,<5.5'
          # bitsandbytes: hard import in unsloth/models/_utils.py. Recent
          # versions ship a CPU build that imports cleanly on Linux.
          pip install 'bitsandbytes>=0.45'
          # unsloth.device_type imports unsloth_zoo.utils.Version at module
          # scope, so the conftest preload needs unsloth_zoo even though
          # it is an optional dep of unsloth.
          pip install 'unsloth_zoo>=2026.5.1'
          pip install -e . --no-deps

      - name: Repo tests (CPU, auto-discovered)
        env:
          # tests/python/* import install_python_stack from studio/.
          PYTHONPATH: ${{ github.workspace }}/studio
          # Skip lazy compilation work the unsloth import chain wants to
          # do at import time on a real GPU.
          UNSLOTH_COMPILE_DISABLE: '1'
        # --ignore: GPU-bound directories (qlora/saving need real weights;
        #   tests/sh is the shell suite the next step handles; tests/utils
        #   is a helpers folder); tests/vllm_compat + tests/version_compat
        #   are dedicated multi-version drift canaries with their own job
        #   in version-compat-ci.yml that installs the heavier dep set
        #   (torchcodec, full transformers/peft/bnb pins) those tests need.
        # State-sensitive hardware-spoofing files run in isolation in the
        # next step because they mutate hardware.py module globals.
        # -m: honour markers from tests/python/conftest.py (`server` =
        #   needs studio venv, `e2e` = needs network).
        # --deselect:
        #   - test_model_registration / test_all_model_registration:
        #     hit huggingface_hub for live model existence checks.
        #   - test_autoconfig_works_with_no_torch_runtime / test_autoconfig_succeeds:
        #     fail because no-torch-runtime.txt does not pin tokenizers
        #     and the latest tokenizers (0.23.1) is incompatible with the
        #     transformers it resolves to. Tracked separately; this is a
        #     real bug in the no-torch install path, not a CI issue.
        run: |
          python -m pytest tests/ -q --tb=short \
            --ignore=tests/qlora \
            --ignore=tests/saving \
            --ignore=tests/utils \
            --ignore=tests/sh \
            --ignore=tests/studio/test_hardware_dispatch_matrix.py \
            --ignore=tests/studio/test_is_mlx_dispatch_gate.py \
            --ignore=tests/vllm_compat \
            --ignore=tests/version_compat \
            -m 'not server and not e2e' \
            --deselect tests/test_model_registry.py::test_model_registration \
            --deselect tests/test_model_registry.py::test_all_model_registration \
            --deselect 'tests/python/test_tokenizers_and_torch_constraint.py::TestE2ETokenizersFix::test_autoconfig_works_with_no_torch_runtime' \
            --deselect 'tests/python/test_tokenizers_and_torch_constraint.py::TestE2EFullNoTorchSandbox::test_autoconfig_succeeds'

      - name: Hardware-spoof tests (state-sensitive, run in isolation)
        env:
          PYTHONPATH: ${{ github.workspace }}/studio
          UNSLOTH_COMPILE_DISABLE: '1'
        # These two files mutate hardware.py module globals at runtime
        # via the spoof fixtures, which leaks state into any other test
        # that imports hardware. Run them in their own pytest invocation
        # so the leak does not cross file boundaries.
        run: |
          python -m pytest -q --tb=short \
            tests/studio/test_hardware_dispatch_matrix.py \
            tests/studio/test_is_mlx_dispatch_gate.py

      - name: Shell installer tests
        # Subset that does not depend on a writable / pristine install.sh
        # tree; test_install_host_defaults.sh checks install.ps1 layout
        # which has drifted (separate followup).
        run: |
          set -e
          for s in \
              tests/sh/test_get_torch_index_url.sh \
              tests/sh/test_mac_intel_compat.sh \
              tests/sh/test_tauri_install_exit_order.sh \
              tests/sh/test_torch_constraint.sh; do
              echo "::group::$s"
              bash "$s"
              echo "::endgroup::"
          done


```

## /.github/workflows/studio-frontend-ci.yml

```yml path="/.github/workflows/studio-frontend-ci.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# Frontend PR gate: lockfile freshness, typecheck, build, and a bundle grep
# that catches the 2026.5.1 chat-history regression at the JS level.
#
# biome runs as non-blocking for now: the codebase currently has accumulated
# ~470 errors and ~1650 warnings against the existing biome config. Surfacing
# the count in CI lets us drive it down without forcing a fleet-wide cleanup
# in the same PR. Drop `continue-on-error` once that number is zero.

name: Frontend CI

on:
  pull_request:
    paths:
      - 'studio/frontend/**'
      - '.github/workflows/studio-frontend-ci.yml'
  push:
    branches: [main, pip]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build:
    name: Frontend build + bundle sanity
    runs-on: ubuntu-latest
    timeout-minutes: 10
    defaults:
      run:
        working-directory: studio/frontend
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      # FIXME: drop this step once @assistant-ui/* and assistant-stream
      # leave 0.x -- on 1.x, caret ranges are conventional. Until then,
      # every 0.minor on this surface is a SemVer-major (this is exactly
      # how 2026.5.1 shipped a broken chat runtime: ^0.12.19 quietly
      # resolved to 0.12.28).
      - name: '@assistant-ui must be pinned exactly (no caret/tilde)'
        working-directory: ${{ github.workspace }}
        run: |
          set -e
          if grep -nE '"(@assistant-ui/[a-z-]+|assistant-stream)":[[:space:]]*"[\^~]' studio/frontend/package.json; then
            echo "::error file=studio/frontend/package.json::These packages must be pinned to exact versions until they leave 0.x. Drop the leading ^ or ~."
            exit 1
          fi
          echo "All assistant-ui packages are pinned exactly."

      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e  # v6.4.0
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: studio/frontend/package-lock.json

      # Run the structural lockfile scan BEFORE npm ci. A compromised
      # tarball runs its `prepare` / `postinstall` during `npm ci`,
      # so any catch has to fire upstream of that. The scanner is
      # pure-Python read-only; safe to call ahead of every install.
      - name: Lockfile supply-chain audit (pre-install scan)
        working-directory: ${{ github.workspace }}
        run: python3 scripts/lockfile_supply_chain_audit.py

      - name: Lockfile must agree with package.json (npm ci is strict)
        # Lifecycle scripts (esbuild native-binary postinstall, etc.) are
        # required for `vite build`. The pre-install lockfile structural
        # audit (lockfile_supply_chain_audit.py) is the practical defence
        # against the npm postinstall-dropper class -- it fires BEFORE any
        # tarball runs, on the injection pattern itself rather than an
        # advisory-DB lookup.
        run: npm ci --no-fund --no-audit

      - name: npm ci must not have modified the working tree
        working-directory: ${{ github.workspace }}
        run: |
          if ! git diff --quiet -- studio/frontend; then
            echo "::error::npm ci modified files; commit the updated lockfile"
            git status -- studio/frontend
            exit 1
          fi

      - name: Typecheck
        run: npm run typecheck

      - name: Build
        run: npm run build

      - name: Built bundle must not contain Studio's unstable_Provider call site
        run: |
          set -e
          JS=$(ls dist/assets/index-*.js | head -1)
          HITS=$(grep -c 'unstable_Provider:' "$JS" || echo 0)
          echo "main bundle: $JS"
          echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)"
          if [ "$HITS" -gt 3 ]; then
            echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
            exit 1
          fi

      - name: Bundle size budget (75 MB)
        run: |
          SIZE=$(du -sb dist | cut -f1)
          BUDGET=$((75 * 1024 * 1024))
          echo "dist size: $SIZE bytes ($((SIZE/1024/1024)) MB), budget: $BUDGET bytes (75 MB)"
          if [ "$SIZE" -gt "$BUDGET" ]; then
            echo "::error::studio/frontend/dist/ exceeded the 75 MB budget. Drop dead deps (e.g. the unused next dep) or split chunks."
            exit 1
          fi

      - name: Biome (non-blocking until accumulated drift is cleared)
        continue-on-error: true
        run: npm run biome:check

      - name: Upload built dist
        # Always upload so a green run is reviewable too -- the dist
        # output catches "tests passed but bundle changed unexpectedly"
        # regressions that would be invisible if we only kept artifacts
        # on failure.
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7.0.1
        with:
          name: studio-frontend-dist
          path: studio/frontend/dist
          retention-days: 3

```

## /.github/workflows/studio-mac-api-smoke.yml

```yml path="/.github/workflows/studio-mac-api-smoke.yml" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

# Mac counterpart to studio-api-smoke.yml. Same tests/studio/
# studio_api_smoke.py exercise (CORS hardening, auth state machine,
# JWT expiry, API key lifecycle, /v1/models / /v1/embeddings /
# /v1/responses, endpoint-by-endpoint auth audit) but on a real
# Apple Silicon (macos-14, M1) runner. Drops the apt-get block;
# GitHub-hosted macos-14 ships curl + jq.

name: Mac Studio API CI

on:
  pull_request:
    paths:
      - 'studio/**'
      - 'unsloth/**'
      - 'unsloth_cli/**'
      - 'install.sh'
      - 'pyproject.toml'
      - 'tests/studio/**'
      - '.github/workflows/studio-mac-api-smoke.yml'
  push:
    branches: [main, pip]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  api-smoke:
    name: Studio API & Auth Tests
    runs-on: macos-14
    timeout-minutes: 25
    env:
      GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
      GGUF_VARIANT: UD-Q4_K_XL
      GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
      STUDIO_PORT: '18895'
      HF_HOME: ${{ github.workspace }}/hf-cache
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e  # v6.4.0
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: studio/frontend/package-lock.json

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Restore HF_HOME for ${{ env.GGUF_REPO }}
        id: cache-hf
        uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae  # v5.0.5
        continue-on-error: true
        with:
          path: hf-cache
          key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1

      - name: Prime HF_HOME with the GGUF
        id: prime-hf
        if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
        run: |
          python -m pip install --upgrade huggingface_hub hf_transfer
          mkdir -p hf-cache
          HF_HUB_ENABLE_HF_TRANSFER=1 \
            hf download "$GGUF_REPO" "$GGUF_FILE"

      - name: Save HF_HOME for ${{ env.GGUF_REPO }}
        if: always() && steps.prime-hf.outcome == 'success'
        uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae  # v5.0.5
        with:
          path: hf-cache
          key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1

      - name: Install Studio (--local, --no-torch)
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          mkdir -p logs
          set -o pipefail
          bash install.sh --local --no-torch 2>&1 | tee logs/install.log

      - name: Assert install.sh used the Mac llama.cpp prebuilt
        run: |
          if grep -q "falling back to source build" logs/install.log; then
            echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
            grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
            exit 1
          fi

      - name: Install pyjwt for the JWT-expiry forge test
        run: pip install 'pyjwt>=2.6'

      - name: Reset auth + boot Studio (API-only)
        run: |
          unsloth studio reset-password
          mkdir -p logs
          UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
            > logs/studio.log 2>&1 &
          echo "STUDIO_PID=$!" >> "$GITHUB_ENV"

      - name: Wait for /api/health
        run: |
          for i in $(seq 1 180); do
            if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
              jq -e '.status == "healthy"' /tmp/health.json && break
            fi
            sleep 1
          done
          jq -e '.status == "healthy"' /tmp/health.json

      - name: Pass bootstrap password + rotated targets to the test
        run: |
          OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
          NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
          NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
          echo "::add-mask::$OLD"
          echo "::add-mask::$NEW"
          echo "::add-mask::$NEW2"
          echo "STUDIO_OLD_PW=$OLD"  >> "$GITHUB_ENV"
          echo "STUDIO_NEW_PW=$NEW"  >> "$GITHUB_ENV"
          echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"

      - name: Run Studio API & Auth tests
        env:
          BASE_URL: http://127.0.0.1:18895
          STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth
        run: python tests/studio/studio_api_smoke.py

      - name: Stop Studio
        if: always()
        run: |
          kill "${STUDIO_PID}" 2>/dev/null || true
          sleep 2

      - name: Upload API smoke logs
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7.0.1
        with:
          name: mac-studio-api-smoke-log
          path: |
            logs/install.log
            logs/studio.log
          retention-days: 7

```

## /.pre-commit-ci.yaml

```yaml path="/.pre-commit-ci.yaml" 
ci:
  autofix_prs: true
  autofix_prs_limit: 5
  autoupdate_schedule: monthly
  autoupdate_commit_msg: "chore: pre-commit autoupdate"
  skip: []

```

## /.pre-commit-config.yaml

```yaml path="/.pre-commit-config.yaml" 
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.12
    hooks:
      - id: ruff
        args:
          - --fix
          - --exit-non-zero-on-fix
        exclude: '\.ipynb{{contextString}}#39;
  - repo: local
    hooks:
      - id: ruff-format-with-kwargs
        name: Ruff format with kwarg spacing
        entry: scripts/run_ruff_format.py
        language: python
        types: [python]
        additional_dependencies:
          - ruff==0.6.9

```

## /cli.py

```py path="/cli.py" 
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

from unsloth_cli import app

if __name__ == "__main__":
    app()

```

## /images/Assistant.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Assistant.png

## /images/Colab.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Colab.png

## /images/Discord button.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Discord button.png

## /images/Discord.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Discord.png

## /images/Documentation Button.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Documentation Button.png

## /images/Free version button.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Free version button.png

## /images/Kaggle.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Kaggle.png

## /images/Kofi button.png

Binary file available at https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/main/images/Kofi button.png


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!